diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml
index 70d1b394e..9dad1d45e 100644
--- a/.github/workflows/bundle.yml
+++ b/.github/workflows/bundle.yml
@@ -1,4 +1,3 @@
-# Workflow stub to build Toolbox bundles.
name: PyInstaller Bundle
on: workflow_dispatch
@@ -6,13 +5,59 @@ on: workflow_dispatch
jobs:
bundle:
name: Bundle
- strategy:
- fail-fast: True
- matrix:
- python-version: ["3.11"]
- os: [windows-latest]
runs-on: windows-latest
steps:
- - name: Print notification
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: "Set up Python"
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+ cache: pip
+ - name: Install dependencies
run: |
- echo "This workflow is stub and does not do anything meaningful."
+ python -m pip install --upgrade pip
+ python -m pip install -r requirements.txt
+ - name: Run tests
+ run: |
+ python -m unittest discover --verbose
+ - name: Install PyInstaller
+ run: |
+ python -m pip install PyInstaller
+ - name: Download embeddable Python
+ run: |
+ mkdir embedded-python
+ cd embedded-python
+ curl -o python.zip https://www.python.org/ftp/python/3.11.9/python-3.11.9-embed-amd64.zip
+ tar xf python.zip
+ del python.zip
+ - name: Build bundle
+ run: |
+ python -m PyInstaller spinetoolbox.spec -- --embedded-python=embedded-python
+ - name: Get Toolbox version
+ id: toolbox-version
+ shell: bash
+ run: |
+ python -c "from importlib.metadata import version; print('version=' + version('spinetoolbox'))" >> $GITHUB_OUTPUT
+ - name: Upload archive
+ uses: actions/upload-artifact@v4
+ with:
+ name: Spine Toolbox ${{ steps.toolbox-version.outputs.version }}
+ path: dist
+ if-no-files-found: error
+ overwrite: true
+ update-downloads-page:
+ name: Trigger Downloads page generation
+ needs: bundle
+ runs-on: ubuntu-latest
+ steps:
+ - name: Trigger workflow
+ run: |
+ curl -L \
+ -X POST \
+ -H "Accept: application/vnd.github+json" \
+ -H "Authorization: Bearer ${{ secrets.DOWNLOADS_TRIGGER_TOKEN }}" \
+ -H "X-GitHub-Api-Version: 2022-11-28" \
+ https://api.github.com/repos/spine-tools/Downloads/actions/workflows/generate_readme.yml/dispatches \
+ -d '{"ref":"main"}'
diff --git a/.github/workflows/test_runner.yml b/.github/workflows/test_runner.yml
index 76bc944a6..5ca43dedb 100644
--- a/.github/workflows/test_runner.yml
+++ b/.github/workflows/test_runner.yml
@@ -6,7 +6,7 @@ on:
push:
paths:
- "**.py"
- - ".github/workflows/*.yml"
+ - ".github/workflows/test_runner.yml"
- "requirements.txt"
- "pyproject.toml"
- "execution_tests/**"
@@ -21,15 +21,19 @@ jobs:
python-version: [3.8, 3.9, "3.10", 3.11]
os: [windows-latest, ubuntu-22.04]
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Version from Git tags
run: git describe --tags
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
+ cache: pip
+ cache-dependency-path: |
+ requirements.txt
+ pyproject.toml
- name: Display Python version
run:
python -c "import sys; print(sys.version)"
@@ -45,12 +49,15 @@ jobs:
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
- name: List packages
- run:
+ run:
python -m pip list
- name: Install python3 kernelspecs
run: |
python -m pip install ipykernel
python -m ipykernel install --user
+ - name: Install coverage
+ run:
+ python -m pip install coverage[toml]
- name: Run tests
run: |
if [ "$RUNNER_OS" != "Windows" ]; then
@@ -59,8 +66,9 @@ jobs:
coverage run -m unittest discover --verbose
shell: bash
- name: Upload coverage report to Codecov
- uses: codecov/codecov-action@v3
-
+ uses: codecov/codecov-action@v4
+ env:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
execution-tests:
name: Execution tests
runs-on: ${{ matrix.os }}
@@ -69,15 +77,17 @@ jobs:
matrix:
python-version: [3.8, 3.9, "3.10", 3.11]
os: [windows-latest, ubuntu-22.04]
- needs: unit-tests
+# needs: unit-tests
steps:
- - uses: actions/checkout@v3
- with:
- fetch-depth: 0
+ - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
+ cache: pip
+ cache-dependency-path: |
+ requirements.txt
+ pyproject.toml
- name: Install additional packages for Linux
if: runner.os == 'Linux'
run: |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec97a64ed..1f973ec52 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,12 +6,53 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
## [Unreleased]
### Added
+- New context menu action (Select superclass) for entity class items in the entity tree.
+- Added Tool Specification type (Python, Gams, etc.) icons on Design View.
+- There is now a new filter type, Alternative filter available in Link properties.
+ Unlike scenario filters, the execution is not parallelized.
+ Instead, a successor item sees parameter values of all selected alternatives.
+ Because of this behavior,
+ alternative filters cannot be used at the same time with scenario filters.
+ Link properties tab has a combo box that lets one choose which filter type to use.
### Changed
+#### Spine data structure
+
+Many parts of the Spine data structure have been redesigned.
+
+- *Entities* have replaced objects and relationships.
+ Zero-dimensional entities correspond to objects while multidimensional entities replace the former relationships.
+ Unlike relationships, the *elements* of multidimensional entities can now be other multidimensional entities.
+- Simple inheritance is now supported by *superclasses*.
+- Tools, features and methods have been removed.
+ The functionality that was previously implemented using the is_active parameter
+ has been replaced by *entity alternatives*.
+ Entity classes have a default setting for the entity alternative called *active by default*.
+ Database migration should automatically replace tools, features and methods
+ by entity alternatives and set active by default to whatever default value `is_active`
+ or similar parameter had.
+ The `is_active` parameter is not removed from entity classes but its values are.
+- Note that new zero-dimensional entity classes have *active by default* set to `false` initially.
+ This means that the entities of those classes are hidden when using scenario filters
+ unless specifically shown using entity alternatives.
+
+#### Miscellaneous changes
+
+- "Rubber band" selection of items in Design and Graph views is now done with **left mouse button**
+ (no need to press Ctrl anymore). The views can be dragged around by holding the **right mouse button**.
+- Spine Database Editor now remembers the configuration of the docs in each view for a specific URL. The docks
+ can be reset from the hamburger menu **View->Docks...->Reset docks**.
+- You can now select a different Julia executable & project or Julia kernel for each Tool spec.
+ This overrides the global setting from Toolbox Settings.
+- Headless mode now supports remote execution (see 'python -m spinetoolbox --help')
+- Commit Viewer's UI has undergone some redesigning and can now handle large databases.
+
### Deprecated
### Removed
+- Project dock widget
+- Dependency on Dagster
### Fixed
@@ -30,14 +71,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
- On Toolbox startup, a link appears in Event log that opens Upgrade notification window
offering information about the upcoming 0.8 update.
-## [0.7.2]
+## [0.7.2] - 2023-12-04
### Added
- Data Connection items now support schemas in database references
- Importer Specification Editor now supports database schemas
-## [0.7.1]
+## [0.7.1] - 2023-10-06
### Added
diff --git a/PyInstaller hooks/hook-datapackage.py b/PyInstaller hooks/hook-datapackage.py
new file mode 100644
index 000000000..d8c22720e
--- /dev/null
+++ b/PyInstaller hooks/hook-datapackage.py
@@ -0,0 +1,3 @@
+from PyInstaller.utils.hooks import collect_data_files
+
+datas = collect_data_files("datapackage")
diff --git a/PyInstaller hooks/hook-jill.py b/PyInstaller hooks/hook-jill.py
new file mode 100644
index 000000000..e0ad0fa5a
--- /dev/null
+++ b/PyInstaller hooks/hook-jill.py
@@ -0,0 +1,3 @@
+from PyInstaller.utils.hooks import collect_data_files
+
+datas = collect_data_files("jill", subdir="config")
diff --git a/PyInstaller hooks/hook-spine_engine.py b/PyInstaller hooks/hook-spine_engine.py
new file mode 100644
index 000000000..b776c3ba5
--- /dev/null
+++ b/PyInstaller hooks/hook-spine_engine.py
@@ -0,0 +1,5 @@
+from PyInstaller.utils.hooks import collect_data_files
+
+datas = collect_data_files(
+ "spine_engine", subdir="execution_managers", includes=("spine_repl.*",), include_py_files=True
+)
diff --git a/PyInstaller hooks/hook-spinedb_api.py b/PyInstaller hooks/hook-spinedb_api.py
new file mode 100644
index 000000000..872c6cc4a
--- /dev/null
+++ b/PyInstaller hooks/hook-spinedb_api.py
@@ -0,0 +1,3 @@
+from PyInstaller.utils.hooks import collect_data_files
+
+datas = collect_data_files("spinedb_api", subdir="alembic", include_py_files=True)
diff --git a/PyInstaller hooks/hook-tableschema.py b/PyInstaller hooks/hook-tableschema.py
new file mode 100644
index 000000000..c958b2d1f
--- /dev/null
+++ b/PyInstaller hooks/hook-tableschema.py
@@ -0,0 +1,6 @@
+from PyInstaller.utils.hooks import collect_data_files
+
+package = "tableschema"
+
+datas = collect_data_files(package)
+datas += collect_data_files(package, subdir="profiles")
diff --git a/PyInstaller hooks/hook-tabulator.py b/PyInstaller hooks/hook-tabulator.py
new file mode 100644
index 000000000..c9e3560f5
--- /dev/null
+++ b/PyInstaller hooks/hook-tabulator.py
@@ -0,0 +1,5 @@
+from PyInstaller.utils.hooks import collect_data_files
+
+datas = collect_data_files("tabulator")
+datas += collect_data_files("tabulator", subdir="loaders", include_py_files=True)
+datas += collect_data_files("tabulator", subdir="parsers", include_py_files=True)
diff --git a/README.md b/README.md
index f90b69cb8..4f93010bb 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,7 @@ We provide three options for installing Spine Toolbox. The first two options als
the Pre-installation steps**:
- [Python/pipx](#installation-with-python-and-pipx) (we intend to make stable releases every month or so)
- [From source files](#installation-from-sources-using-git) (this is the cutting edge - and more likely to have bugs)
+- [Windows executable as .zip bundle](https://spine-tools.github.io/Downloads/) (requires only unzipping the downloaded .zip file)
- [Windows installation package](#windows-64-bit-installer-package) (these are quite old - not recommended)
### Pre-installation
@@ -124,6 +125,7 @@ or upgrade the *development* version with
python -m pipx upgrade spinetoolbox-dev
+
### Installation from sources using Git
This option is for developers and other contributors who want to debug or edit Spine Toolbox source code. Once
diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/benchmarks/compound_model_filter_accepts_model.py b/benchmarks/compound_model_filter_accepts_model.py
new file mode 100644
index 000000000..bea0dedcd
--- /dev/null
+++ b/benchmarks/compound_model_filter_accepts_model.py
@@ -0,0 +1,61 @@
+"""
+This script benchmarks CompoundModelBase.filter_accepts_model().
+"""
+import os
+import sys
+
+if sys.platform == "win32" and "HOMEPATH" not in os.environ:
+ import pathlib
+ os.environ["HOMEPATH"] = str(pathlib.Path(sys.executable).parent)
+
+import time
+from typing import Optional
+import pyperf
+from PySide6.QtCore import QSettings
+from PySide6.QtWidgets import QApplication
+from benchmarks.utils import StdOutLogger
+from spinetoolbox.spine_db_manager import SpineDBManager
+from spinetoolbox.spine_db_editor.mvcmodels.compound_models import CompoundModelBase, CompoundParameterValueModel
+from spinetoolbox.spine_db_editor.mvcmodels.single_models import SingleModelBase
+
+
+def call_filter_accepts_model(
+ loops: int, compound_model: CompoundModelBase, single_model: SingleModelBase
+) -> float:
+ duration = 0.0
+ for _ in range(loops):
+ start = time.perf_counter()
+ compound_model.filter_accepts_model(single_model)
+ duration += time.perf_counter() - start
+ return duration
+
+
+def run_benchmark(output_file: Optional[str]):
+ if not QApplication.instance():
+ QApplication()
+ db_mngr = SpineDBManager(QSettings(), parent=None)
+ logger = StdOutLogger()
+ db_map = db_mngr.get_db_map("sqlite://", logger, create=True)
+ entity_class, error = db_map.add_entity_class_item(name="Object")
+ assert error is None
+ db_map.add_entity_class_item(name="Subject")
+ relationship_class, error = db_map.add_entity_class_item(name="Object__Subject", dimension_name_list=("Object", "Subject"))
+ assert error is None
+ compound_model = CompoundParameterValueModel(None, db_mngr, db_map)
+ compound_model.set_filter_class_ids({db_map: {entity_class["id"]}})
+ single_model = SingleModelBase(compound_model, db_map, relationship_class["id"], committed=False)
+ runner = pyperf.Runner()
+ benchmark = runner.bench_time_func(
+ "CompoundModelBase.filter_accepts_model[filter by class ids]",
+ call_filter_accepts_model,
+ compound_model,
+ single_model
+ )
+ if output_file:
+ pyperf.add_runs(output_file, benchmark)
+ db_mngr.close_all_sessions()
+ db_mngr.deleteLater()
+
+
+if __name__ == "__main__":
+ run_benchmark(output_file="")
diff --git a/benchmarks/db_mngr_get_icon_mngr.py b/benchmarks/db_mngr_get_icon_mngr.py
new file mode 100644
index 000000000..8cc31ecfd
--- /dev/null
+++ b/benchmarks/db_mngr_get_icon_mngr.py
@@ -0,0 +1,65 @@
+"""
+This script benchmarks SpineDBManager.get_icon_mngr().
+"""
+import os
+import sys
+
+if sys.platform == "win32" and "HOMEPATH" not in os.environ:
+ import pathlib
+ os.environ["HOMEPATH"] = str(pathlib.Path(sys.executable).parent)
+
+import time
+from typing import Optional, Iterable
+import pyperf
+from PySide6.QtCore import QSettings
+from PySide6.QtWidgets import QApplication
+from spinetoolbox.spine_db_manager import SpineDBManager
+from spinedb_api import DatabaseMapping
+from spinetoolbox.spine_db_icon_manager import SpineDBIconManager
+
+
+def db_mngr_get_icon_mngr(
+ loops: int, db_mngr: SpineDBManager, db_maps: Iterable[DatabaseMapping]
+) -> float:
+ duration = 0.0
+ for _ in range(loops):
+ for db_map in db_maps:
+ start = time.perf_counter()
+ icon_mngr = db_mngr.get_icon_mngr(db_map)
+ duration += time.perf_counter() - start
+ assert isinstance(icon_mngr, SpineDBIconManager)
+ return duration
+
+
+def run_benchmark(output_file: Optional[str]):
+ if not QApplication.instance():
+ QApplication()
+ db_mngr = SpineDBManager(QSettings(), parent=None)
+ inner_loops = 10
+ db_maps = [DatabaseMapping("sqlite://", create=True) for _ in range(inner_loops)]
+ runner = pyperf.Runner()
+ benchmark = runner.bench_time_func(
+ "SpineDBManager.get_icon_mngr[always different db map]",
+ db_mngr_get_icon_mngr,
+ db_mngr,
+ db_maps,
+ inner_loops=inner_loops,
+ )
+ if output_file:
+ pyperf.add_runs(output_file, benchmark)
+ db_maps = inner_loops * [DatabaseMapping("sqlite://", create=True)]
+ benchmark = runner.bench_time_func(
+ "SpineDBManager.get_icon_mngr[always same db_map]",
+ db_mngr_get_icon_mngr,
+ db_mngr,
+ db_maps,
+ inner_loops=inner_loops,
+ )
+ if output_file:
+ pyperf.add_runs(output_file, benchmark)
+ db_mngr.close_all_sessions()
+ db_mngr.deleteLater()
+
+
+if __name__ == "__main__":
+ run_benchmark(output_file="")
diff --git a/benchmarks/db_mngr_get_item.py b/benchmarks/db_mngr_get_item.py
new file mode 100644
index 000000000..c20dae680
--- /dev/null
+++ b/benchmarks/db_mngr_get_item.py
@@ -0,0 +1,63 @@
+"""
+This script benchmarks SpineDBManager.get_item().
+"""
+import os
+import sys
+
+if sys.platform == "win32" and "HOMEPATH" not in os.environ:
+ import pathlib
+ os.environ["HOMEPATH"] = str(pathlib.Path(sys.executable).parent)
+
+import time
+from typing import Optional, Sequence
+import pyperf
+from PySide6.QtCore import QSettings
+from PySide6.QtWidgets import QApplication
+from spinedb_api import DatabaseMapping
+from spinedb_api.temp_id import TempId
+from spinetoolbox.spine_db_manager import SpineDBManager
+from benchmarks.utils import StdOutLogger
+
+
+def db_mngr_get_value(
+ loops: int, db_mngr: SpineDBManager, db_map: DatabaseMapping, item_type, ids: Sequence[TempId]
+) -> float:
+ duration = 0.0
+ for _ in range(loops):
+ for id_ in ids:
+ start = time.perf_counter()
+ db_mngr.get_item(db_map, item_type, id_)
+ duration += time.perf_counter() - start
+ return duration
+
+
+def run_benchmark(output_file: Optional[str]):
+ if not QApplication.instance():
+ QApplication()
+ db_mngr = SpineDBManager(QSettings(), parent=None)
+ logger = StdOutLogger()
+ db_map = db_mngr.get_db_map("sqlite://", logger, create=True)
+ inner_loops = 10
+ ids = []
+ for i in range(inner_loops):
+ item, error = db_map.add_entity_class_item(name=str(i))
+ assert error is None
+ ids.append(item["id"])
+ runner = pyperf.Runner()
+ benchmark = runner.bench_time_func(
+ "SpineDBManager.get_value[parameter_value, DisplayRole]",
+ db_mngr_get_value,
+ db_mngr,
+ db_map,
+ "entity_class",
+ ids,
+ inner_loops=inner_loops,
+ )
+ if output_file:
+ pyperf.add_runs(output_file, benchmark)
+ db_mngr.close_all_sessions()
+ db_mngr.deleteLater()
+
+
+if __name__ == "__main__":
+ run_benchmark(output_file="")
diff --git a/benchmarks/db_mngr_get_value.py b/benchmarks/db_mngr_get_value.py
new file mode 100644
index 000000000..59358ccf1
--- /dev/null
+++ b/benchmarks/db_mngr_get_value.py
@@ -0,0 +1,75 @@
+"""
+This script benchmarks SpineDBManager.get_value().
+"""
+import os
+import sys
+
+if sys.platform == "win32" and "HOMEPATH" not in os.environ:
+ import pathlib
+ os.environ["HOMEPATH"] = str(pathlib.Path(sys.executable).parent)
+
+import time
+from typing import Optional, Sequence
+import pyperf
+from PySide6.QtCore import QSettings, Qt
+from PySide6.QtWidgets import QApplication
+from spinedb_api.db_mapping_base import PublicItem
+from spinedb_api import DatabaseMapping, to_database
+from spinetoolbox.spine_db_manager import SpineDBManager
+from benchmarks.utils import StdOutLogger
+
+
+def db_mngr_get_value(
+ loops: int, db_mngr: SpineDBManager, db_map: DatabaseMapping, items: Sequence[PublicItem], role: Qt.ItemDataRole
+) -> float:
+ duration = 0.0
+ for _ in range(loops):
+ for item in items:
+ start = time.perf_counter()
+ db_mngr.get_value(db_map, item, role)
+ duration += time.perf_counter() - start
+ return duration
+
+
+def run_benchmark(output_file: Optional[str]):
+ if not QApplication.instance():
+ QApplication()
+ db_mngr = SpineDBManager(QSettings(), parent=None)
+ logger = StdOutLogger()
+ db_map = db_mngr.get_db_map("sqlite://", logger, create=True)
+ db_map.add_entity_class_item(name="Object")
+ db_map.add_parameter_definition_item(name="x", entity_class_name="Object")
+ db_map.add_entity_item(name="object", entity_class_name="Object")
+ value_items = []
+ for i in range(100):
+ alternative_name = str(i)
+ db_map.add_alternative_item(name=str(i))
+ value, value_type = to_database(i)
+ item, error = db_map.add_parameter_value_item(
+ entity_class_name="Object",
+ parameter_definition_name="x",
+ entity_byname=("object",),
+ alternative_name=alternative_name,
+ value=value,
+ type=value_type,
+ )
+ assert error is None
+ value_items.append(item)
+ runner = pyperf.Runner()
+ benchmark = runner.bench_time_func(
+ "SpineDBManager.get_value[parameter_value, DisplayRole]",
+ db_mngr_get_value,
+ db_mngr,
+ db_map,
+ value_items,
+ Qt.ItemDataRole.DisplayRole,
+ inner_loops=len(value_items),
+ )
+ if output_file:
+ pyperf.add_runs(output_file, benchmark)
+ db_mngr.close_all_sessions()
+ db_mngr.deleteLater()
+
+
+if __name__ == "__main__":
+ run_benchmark(output_file="")
diff --git a/benchmarks/utils.py b/benchmarks/utils.py
new file mode 100644
index 000000000..5060e7f7d
--- /dev/null
+++ b/benchmarks/utils.py
@@ -0,0 +1,9 @@
+class _EmitPrinter:
+ @staticmethod
+ def emit(text):
+ print(text)
+
+
+class StdOutLogger:
+ msg = _EmitPrinter()
+ msg_error = _EmitPrinter()
diff --git a/bin/append_license.py b/bin/append_license.py
index 70b873d57..9d68e7b94 100755
--- a/bin/append_license.py
+++ b/bin/append_license.py
@@ -6,6 +6,7 @@
license_text = [
"######################################################################################################################\n",
"# Copyright (C) 2017-2022 Spine project consortium\n",
+"# Copyright Spine Toolbox contributors\n",
"# This file is part of Spine Toolbox.\n",
"# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General\n",
"# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option)\n",
diff --git a/bin/update_copyrights.py b/bin/update_copyrights.py
deleted file mode 100644
index e41a6a39e..000000000
--- a/bin/update_copyrights.py
+++ /dev/null
@@ -1,39 +0,0 @@
-#!/usr/bin/env python
-
-from pathlib import Path
-import time
-
-
-current_year = time.gmtime().tm_year
-root_dir = Path(__file__).parent.parent
-project_source_dir = Path(root_dir, "spinetoolbox")
-test_source_dir = Path(root_dir, "tests")
-
-expected = f"# Copyright (C) 2017-{current_year} Spine project consortium"
-
-
-def update_copyrights(path, suffix, recursive=True):
- for path in path.iterdir():
- if path.suffix == suffix:
- i = 0
- with open(path) as python_file:
- lines = python_file.readlines()
- for i, line in enumerate(lines[1:4]):
- if line.startswith("# Copyright (C) "):
- lines[i + 1] = lines[i + 1][:21] + str(current_year) + lines[i + 1][25:]
- break
- if len(lines) <= i + 1 or not lines[i + 1].startswith(expected):
- print(f"Confusing or no copyright: {path}")
- else:
- with open(path, "w") as python_file:
- python_file.writelines(lines)
- elif recursive and path.is_dir():
- update_copyrights(path, suffix)
-
-
-update_copyrights(root_dir, ".py", recursive=False)
-update_copyrights(project_source_dir, ".py")
-update_copyrights(project_source_dir, ".ui")
-update_copyrights(test_source_dir, ".py")
-
-print("Done. Don't forget to update append_license.py!")
diff --git a/build_utils/path.pth b/build_utils/path.pth
deleted file mode 100644
index 255e72c98..000000000
--- a/build_utils/path.pth
+++ /dev/null
@@ -1,3 +0,0 @@
-lib
-lib/library.zip
-python37.dll
diff --git a/build_utils/pyvenv.cfg b/build_utils/pyvenv.cfg
deleted file mode 100644
index 40b0cc6da..000000000
--- a/build_utils/pyvenv.cfg
+++ /dev/null
@@ -1 +0,0 @@
-include-system-site-packages = false
diff --git a/build_utils/sitecustomize.py b/build_utils/sitecustomize.py
deleted file mode 100644
index ac08930ad..000000000
--- a/build_utils/sitecustomize.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""This file modifies the site packages of the embedded python interpreter
-in tools/python.exe. It removes two paths from PYTHONPATH and adds
-library.zip into it's site-packages."""
-
-import site
-import os
-import sys
-
-p = sys.path
-try:
- p.remove("C:\\Python37\\Lib")
-except ValueError:
- pass
-try:
- p.remove("C:\\Python37\\DLLs")
-except ValueError:
- pass
-sys.path = p
-python_exe_dir, _ = os.path.split(sys.executable)
-site.addsitedir(os.path.join(python_exe_dir, os.pardir, "lib"))
-site.addsitedir(os.path.join(python_exe_dir, os.pardir, "lib", "library.zip"))
diff --git a/cx_Freeze_setup.py b/cx_Freeze_setup.py
index b8a76e385..09bf8888a 100644
--- a/cx_Freeze_setup.py
+++ b/cx_Freeze_setup.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -12,122 +13,100 @@
"""
cx-Freeze setup file for Spine Toolbox.
-Requires: Python3.7-64bit. cx_Freeze 6.6.
-
-NOTE: This file is for release-0.6 branch.
-
-To make a Spine Toolbox installation bundle, follow these steps:
-
On Windows:
-1. Build application with command 'python cx_Freeze_setup.py build'
-2. Check version numbers and CHANGELOG
-3. Compile setup.iss file with Inno Setup. This will create a single-file (.exe) installer.
+1. Download *embeddable* Python packages from https://www.python.org/downloads/windows/
+2. Unzip the downloaded package somewhere.
+3. Build application with command 'python cx_Freeze_setup.py build --embedded-python='
+4. Check version numbers and CHANGELOG
-On other platforms (not tested):
-1. Build the application into build/ directory with command 'python cx_Freeze_setup.py build'
-2. Use cx_Freeze_setup.py (this file) and Cx_Freeze (see Cx_Freeze documentation for help)
"""
-
-import os
+import argparse
+from pathlib import Path
import sys
from cx_Freeze import setup, Executable
-from spinetoolbox.config import APPLICATION_PATH
-
-version = {}
-with open("spinetoolbox/version.py") as fp:
- exec(fp.read(), version)
+from cx_Freeze.cli import prepare_parser
+import ijson
+import spinedb_api
+import spine_engine
+from spine_engine.config import BUNDLE_DIR
+import spine_items
-def main(argv):
+def main():
"""Main of cx_Freeze_setup.py."""
- python_dir, python_exe = os.path.split(sys.executable)
- python37_dll = os.path.join(python_dir, "python37.dll")
- os.environ['TCL_LIBRARY'] = os.path.join(python_dir, "tcl", "tcl8.6")
- os.environ['TK_LIBRARY'] = os.path.join(python_dir, "tcl", "tk8.6")
- # tcl86t.dll and tk86t.dll are required by tkinter, which in turn is required by matplotlib
- tcl86t_dll = os.path.join(python_dir, "DLLs", "tcl86t.dll")
- tk86t_dll = os.path.join(python_dir, "DLLs", "tk86t.dll")
- # Path to built documentation (No need for sources)
- doc_path = os.path.abspath(os.path.join(APPLICATION_PATH, os.path.pardir, "docs", "build"))
- # Paths to files that should be included as is (changelog, readme, licence files, alembic version files)
- changelog_file = os.path.abspath(os.path.join(APPLICATION_PATH, os.path.pardir, "CHANGELOG.md"))
- readme_file = os.path.abspath(os.path.join(APPLICATION_PATH, os.path.pardir, "README.md"))
- copying_file = os.path.abspath(os.path.join(APPLICATION_PATH, os.path.pardir, "COPYING"))
- copying_lesser_file = os.path.abspath(os.path.join(APPLICATION_PATH, os.path.pardir, "COPYING.LESSER"))
- alembic_version_files = alembic_files(python_dir)
- pyvenv_cfg = os.path.abspath(os.path.join(APPLICATION_PATH, os.path.pardir, "build_utils", "pyvenv.cfg"))
- path_pth = os.path.abspath(os.path.join(APPLICATION_PATH, os.path.pardir, "build_utils", "path.pth"))
- site_customize = os.path.abspath(os.path.join(APPLICATION_PATH, os.path.pardir, "build_utils", "sitecustomize.py"))
+ parser = argparse.ArgumentParser(parents=[prepare_parser()], add_help=False)
+ parser.add_argument("--embedded-python", help="path to embedded Python interpreter", required=True)
+ options = parser.parse_args()
+ sys.argv = [arg for arg in sys.argv if not arg.startswith("--embedded-python")]
+ spinedb_api_package = Path(spinedb_api.__file__).parent
+ spinedb_api_root = spinedb_api_package.parent
+ spine_engine_root = Path(spine_engine.__file__).parent.parent
+ spine_items_root = Path(spine_items.__file__).parent.parent
+ root = Path(__file__).parent
+ changelog_file = root / "CHANGELOG.md"
+ readme_file = root / "README.md"
+ copying_file = root / "COPYING"
+ copying_lesser_file = root / "COPYING.LESSER"
# Most dependencies are automatically detected but some need to be manually included.
build_exe_options = {
- "packages": ["packaging", "pkg_resources", "spine_engine", "spine_items", "spinedb_api"],
+ "path": list(set(map(str, (spinedb_api_root, spine_engine_root, spine_items_root)))) + sys.path,
+ "packages": ["ijson.compat", "pendulum.locales", "sqlalchemy.sql.default_comparator", "tabulator.loaders", "tabulator.parsers"],
"excludes": [],
- "includes": [
- "atexit",
- "pygments.lexers.markup",
- "pygments.lexers.python",
- "pygments.lexers.shell",
- "pygments.lexers.julia",
- "pygments.styles.default",
- "qtconsole.client",
- "sqlalchemy.sql.default_comparator",
- "sqlalchemy.ext.baked",
- "ijson.compat",
- "ijson.backends.__init__",
- "ijson.backends.python",
- "ijson.backends.yajl",
- "ijson.backends.yajl2",
- "ijson.backends.yajl2_c",
- "ijson.backends.yajl2_cffi",
- ],
+ "includes": [],
"include_files": [
- (doc_path, "docs/"),
- tcl86t_dll,
- tk86t_dll,
changelog_file,
readme_file,
copying_file,
copying_lesser_file,
- (sys.executable, os.path.join("tools/", python_exe)),
- (python37_dll, os.path.join("tools/", "python37.dll")),
- pyvenv_cfg,
- path_pth,
- site_customize,
]
- + alembic_version_files,
- "include_msvcr": True
+ + alembic_files(spinedb_api_package)
+ + spine_repl_files()
+ + ijson_backends()
+ + embedded_python(options),
}
# Windows specific options
- if os.name == "nt": # Windows specific options
+ if sys.platform == "win32": # Windows specific options
base = "Win32GUI" # set this to "Win32GUI" to not show console, "Console" shows console
else: # Other platforms
base = None
executables = [Executable("spinetoolbox.py", base=base, icon="spinetoolbox/ui/resources/app.ico")]
setup(
- name="Spine Toolbox",
- version=version["__version__"],
- description="An application to define, manage, and execute various energy system simulation models.",
- author="Spine project consortium",
options={"build_exe": build_exe_options},
executables=executables,
)
+ return 0
+
+def embedded_python(options):
+ embedded_python_path = Path(options.embedded_python)
+ return [(str(path), str(Path(BUNDLE_DIR, path.name))) for path in embedded_python_path.iterdir()]
-def alembic_files(python_dir):
+
+def alembic_files(spinedb_api_path):
"""Returns a list of tuples of files in python/Lib/site-packages/spinedb_api/alembic/versions.
First item in tuple is the source file. Second item is the relative destination path to the install directory.
We are including these .py files into 'include_files' list because adding them to the 'includes' list
would require us to give the whole explicit file name.
"""
- dest_dir = os.path.join("lib", "spinedb_api", "alembic", "versions")
- p = os.path.join(python_dir, "Lib", "site-packages", "spinedb_api", "alembic", "versions")
- files = list()
- for f in os.listdir(p):
- if f.endswith(".py"):
- files.append((os.path.abspath(os.path.join(p, f)), os.path.join(dest_dir, f)))
- return files
+ source_dir = spinedb_api_path / "alembic" / "versions"
+ destination_dir = Path("lib", "spinedb_api", "alembic", "versions")
+ return [(str(file), str(destination_dir / file.name)) for file in source_dir.iterdir() if file.suffix == ".py"]
+
+
+def ijson_backends():
+ source_dir = Path(ijson.__file__).parent / "backends"
+ destination_dir = Path("lib", "ijson", "backends")
+ return [(str(file), str(destination_dir / file.name)) for file in source_dir.iterdir() if file.suffix == ".py"]
+
+
+def spine_repl_files():
+ # spine_repl.jl gets copied to the bundle automatically
+ py_repl = Path("execution_managers", "spine_repl.py")
+ source_file = Path(spine_engine.__file__).parent / py_repl
+ destination_file = Path("lib", spine_engine.__name__) / py_repl
+ return [(str(source_file), str(destination_file))]
if __name__ == '__main__':
- sys.exit(main(sys.argv))
+ sys.exit(main())
diff --git a/dev-requirements.txt b/dev-requirements.txt
index 8a575050c..b4c6d6865 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -7,4 +7,6 @@ setuptools_scm[toml] >= 6.2
build
wheel >=0.36.2
twine >= 3.4.1
+coverage[toml]
+pyperf
-r "docs/requirements.txt"
diff --git a/docs/source/data_import_export.rst b/docs/source/data_import_export.rst
index 6d115d4a9..2893026e0 100644
--- a/docs/source/data_import_export.rst
+++ b/docs/source/data_import_export.rst
@@ -69,53 +69,48 @@ question so it is easier to visualize how the importer is handling the data.
Importer specification editor
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-The left side of the **Importer Specification Editor** contains four dock widgets that deal with the input source data.
-**Source files** contains the filepath to the current input file.
-**Source tables** contains the different tables the file has or that user has set up.
-**Source options** has a few options on how to handle the incoming data.
-The available options depend on the connector type.
-**Source data** allows for previewing of the data selected in **Source files**
-and also of the currently selected mapping.
-
-The right side of the spec editor shows import mappings for the table selected in **Source tables**.
-**Mappings** lets you add, duplicate and remove mappings or
-select a mapping to modify its options. **Mapping options** allow you to specify things like what item type
-you are importing. In **Mapping specification** you can select the specific places in the source data where the
-object names, values etc. will be taken from.
+In the upper part of the specification editor, the name and description for the specification can be set.
+Underneath, the filepath is shown and it is allowed to br modified. Next to the filepath the used connector type
+is shown. On the left side, the different tables the file has or that user has set up are shown. Next to that
+there are the connector specific options and underneath that a preview of the selected table is shown. The items in
+the table are highlighted according to the selected mapping. The mappings are listed on the right side of the editor.
+underneath, the to be imported item types can be specified and other options set. Below that
+you can select the specific places in the source data where the entity names, values etc. will be taken from.
.. image:: img/import_editor_window.png
:align: center
-All tables available in the file selected in **Source files** are listed in **Source tables**.
+All tables available in the file selected in **File path** are listed in the leftmost dock widget.
If the file does not have tables or the file type does not support them (e.g. CSV), all of the file's data will be
in a single table called 'data'.
The tables can be selected and deselected using the check boxes
and only the selected ones will be imported.
The option *Select All* is useful for selecting or deselecting all tables.
-If the Importer is opened in `anonymous mode`_, there is also the option to add tables.
+If the Importer is opened in `anonymous mode`_, there is also the option to add tables
.. tip:: Multiple CSV files can be bundled into a *datapackage* which uses its own connector in Importer.
Specifically, each CSV file in the datapackage shows up as a separate table in **Source tables**.
See :ref:`Setting up datapackages in Links` for more information on how to pack CSVs into a datapackage
automatically within your workflow.
-**Source options** contains options to "format" the incoming data. The available options can differ depending on the
-selected connector. The above picture shows the available options for Excel files. **Max rows** specifies the amount of
-rows from the input data that are considered by the Importer. The option **Has header** converts the first row into
-headers. **Skip rows** and **Skip columns** skip the first *N* specified rows or columns from the table.
-If the table has empty rows or columns and some other data after that that you don't want to use,
-**Read until empty row/column on first column/row** options can be used to "crop" the imported data to the
-first relevant block of information. Other possible options for different connector types include
-**Encoding**, **Delimiter**, **Custom Delimiter**, **Quotechar** and **Maximum Depth**.
-**Load default mapping** sets all of the selections in the spec editor
-to their default values. Be careful not to press this button unless you want to wipe the whole specification clean.
+Next to the table dock widget, there is a small dock widget that allows to "format" the incoming data.
+The available options can differ depending on the selected connector. The above picture shows the available
+options for Excel files. **Max rows** specifies the amount of rows from the input data that are considered
+by the Importer. The option **Has header** converts the first row into headers. **Skip rows** and **Skip columns**
+skip the first *N* specified rows or columns from the table. If the table has empty rows or columns and some
+other data after that that you don't want to use, **Read until empty row/column on first column/row** options
+can be used to "crop" the imported data to the first relevant block of information. Other possible options for
+different connector types include **Encoding**, **Delimiter**, **Custom Delimiter**, **Quotechar** and
+**Maximum Depth**. **Load default mapping** sets all of the selections in the spec editor to their default values.
+Be careful not to press this button unless you want to wipe the whole specification clean.
.. note:: If you are working on a specification and accidentally press the *load default mapping* button
you can undo previous changes for the specification from the hamburger menu or by pressing **Ctrl+Z**.
To redo actions, or press **Crl+Y**.
-**Source data** shows the selected table's data and a preview of how the selected mapping will import the data.
-An important aspect of data import is whether each item in the input data should be read as a string, a number,
+When a table is selected, it's data and a preview of how the selected mapping will
+import the data will be presented under the options dock widget. An important aspect of data import is
+whether each item in the input data should be read as a string, a number,
a time stamp or something else. By default all input data is read as strings.
However, more often than not things like parameter values are actually numbers. Though types are usually casted automatically,
it is possible to manually control what type of data each column (and sometimes each row) contains from the preview table.
@@ -127,26 +122,27 @@ Right clicking the column/row header also gives the opportunity to change the da
Under **Mappings** you can manage mappings by adding new ones and removing or duplicating existing ones.
Each table has it's own mappings and every mapping has its own options. In **Mappings** you can select the mapping
-that you want to start modifying. Having multiple mappings for a single table allows to for example import relationship
-classes and object classes at the same time from a single table in a file.
-
-**Mapping options** helps the importer get a feel for what kind of data it will be importing.
-The available *item type* options are *Object class, Relationship class, Object group, Alternative, Scenario,
-Scenario alternative, Parameter value list, Feature, Tool, Tool feature* and *Tool feature method*. The other available
-options are dependent on the Item type. *Import objects* allows to import objects alongside relationships
-or object groups. *Parameter type* is used to specify what type of parameters, if any, the sheet contains. It has options
+that you want to start modifying. Having multiple mappings for a single table allows to for example import
+multiple item types at the same time from a single table in a file.
+
+Underneath **Mappings** there are options that help the importer get a feel for what kind of data it will be importing.
+The available *item type* options are *Entity class, Entity group, Alternative, Scenario,
+Scenario alternative* and *Parameter value list*. The other available
+options are dependent on the Item type. *Import entities* allows to import entities alongside
+or entity groups. *Parameter type* is used to specify what type of parameters, if any, the sheet contains. It has options
*Value, Definition* and *None*. If *Value* or *Definition* is selected
the value or respectively the default value type can be set from the drop-down list. *Use before alternative* is only
available for *Scenario alternative* -item type. *Read data from row* lets you specify the row where the importer
starts to read the data. *Ignore columns* allow you to select individual columns that you want to exclude from the
-whole importing process. *Number of dimensions* sets the amount of dimensions the relationship to be imported has.
+whole importing process. *Number of dimensions* sets the amount of dimensions the entity to be imported has.
*Repeat time series* sets the repeat flag to true when importing time series. *Map dimensions* sets the
-number of map indexes when importing map values.
+number of map indexes when importing map values. *Use before alternative* maps scenario before alternatives when
+importing scenario alternatives. *Compress Maps* can be used to compress value maps.
-Once everything in **Mapping options** is in order, the next step is to set the mapping specification.
-**Mapping specification** is the part where the decisions are made on how the input data is interpreted:
+Once everything in the before mentioned options is in order, the next step is to set the mapping specification.
+Below the options there is the part where the decisions are made on how the input data is interpreted:
which row or column contains the entity class names, parameter values, time stamps and so on.
-The **Mapping specification** dock widget contains all of the targets that the selected mapping options specify.
+The dock widget contains all of the targets that the selected mapping options specify.
Each target has a *Source reference* and a *Source type*. *Source type* specifies if the data for the target
is coming in the form of a column, row, table name etc. In the *Source ref.* section you can pinpoint to the
exact row, column etc. to use as the data. The *Filter* section can be used to further specify which values to
@@ -172,12 +168,12 @@ opening the spec editor from Toolbox **Main Toolbar**.
.. image:: img/importer_spec_editor_anonymous_mode.png
:align: center
-In anonymous mode new tables can be created in **Source tables** by double clicking **
-and writing in a name for the new table. The **Source data** will contain an infinite grid of cells on which you
+In anonymous mode new tables can be created by double clicking **
+and writing in a name for the new table. The preview will show an infinite grid of cells on which you
can create different mappings.
.. note:: You can exit the Anonymous mode by browsing to and selecting an existing file using the controls in
- **Source files** dock.
+ *File path*.
Exporting data with Exporter
****************************
@@ -192,8 +188,8 @@ By default data is mapped to columns but it is also possible to create pivot tab
Exporter also uses specifications so the same configurations
can be reused by other exporters even in other projects.
-The specification can be edited in *Exporter specification editor*
-which is accessible by the |wrench| button in the item's Properties dock
+The specification can be edited in **Exporter specification editor**
+which is accessible by the |wrench| button in the item's **Properties** dock
or by double clicking Exporter's icon on the **Design View**.
A specification that is not associated with any specific Exporter project item can be created
and edited from the Main toolbar.
@@ -201,14 +197,14 @@ and edited from the Main toolbar.
Properties dock
~~~~~~~~~~~~~~~
-Exporter's Properties dock controls project item specific settings
+Exporter's **Properties** dock controls project item specific settings
that are not part of the item's specification.
.. image:: img/exporter_properties.png
:align: center
Specification used by the active Exporter item can be selected from the *Specification* combobox.
-The |wrench| button opens *Exporter specification editor*
+The |wrench| button opens **Exporter specification editor**
where it is possible to edit the specification.
Data Stores that are connected to the exporter and are available for export are listed below
@@ -291,18 +287,18 @@ The currently selected mapping is edited using the controls in *Mapping options*
The *Mapping options* dock contains controls that apply to the mapping as a whole, e.g. what data the output tables
contain. It is important to choose *Item type* correctly since it determines what database items the mapping outputs
and also dictates the mapping types that will be visible in the *Mapping specification* dock widget. It has options
-*Object class, Relationship class, Relationship class with object parameter, Object group, Alternative, Scenario,
-Scenario alternative, Parameter value list, Feature, Tool, Tool feature* and *Tool feature method*. The rest of the
+*Entity class, Entity class with dimension parameter, Entity group, Alternative, Scenario,
+Scenario alternative* and *Parameter value list*. The rest of the
options besides *Group function* are item type specific and may not be available for all selections.
.. image:: img/exporter_mapping_options_dock.png
:align: center
Checking the *Always export header* checkbox outputs a table that has fixed headers even if the table is
-otherwise empty. If *Item type* is Relationship class, the *Relationship dimensions* spinbox can be used
-to specify the maximum number of relationships' dimensions that the mapping is able to handle.
-*Selected dimensions* option is only available for the *Relationship class with object parameter* item
-type and it is used to specify the relationship dimension where the object parameters are selected from.
+otherwise empty. If *Item type* is Entity class, the *Entity dimensions* spinbox can be used
+to specify the maximum number of entity's dimensions that the mapping is able to handle.
+*Selected dimensions* option is only available for the *Entity class with dimension parameter* item
+type and it is used to specify the entity dimension where the entity parameters are selected from.
Parameters can be outputted by choosing their value type using the *Parameter type* combobox. The *Value*
choice adds rows to *Mapping specification* for parameter values associated with individual entities while
*Default value* allows outputting parameters' default values. The maximum number of value dimensions in
@@ -322,7 +318,7 @@ Mapping specification
*Mapping specification* contains a table which defines the structure of the mapping's output tables.
Like mentioned before, the contents of the table depends on choices on *Mapping options*,
e.g. the item type, parameter type or dimensions.
-Each row corresponds to an item in the database: object class names, object names, parameter values etc.
+Each row corresponds to an item in the database: entity class names, entity names, parameter values etc.
The item's name is given in the *Mapping type* column.
The colors help to identify the corresponding elements in the preview.
@@ -331,16 +327,16 @@ that is, where the item is written or otherwise used when the output tables are
By default, a plain integral number in this column means that the item is written to that column in the output table.
From the other choices, *hidden* means that the item will not show on the output.
*Table name*, on the other hand, uses the item as output table names.
-For example, outputting object classes as table names will generate one new table for every object class
+For example, outputting entity classes as table names will generate one new table for every entity class
in the database, each named after the class.
-Each table in turn will contain the parameters and objects of the table's object class.
+Each table in turn will contain the parameters and entities of the table's entity class.
If multiple mappings generate a table with a common name then each mapping appends to the same table
in the order specified by the *Write order* column on *Mappings* dock.
The *column header* position makes the item a column header for a **buddy item**.
Buddy items have some kind of logical relationship with their column header,
-for instance the buddy of an object class is its objects;
-setting the object class to *column header* will write the name of the class as the objects' column header.
+for instance the buddy of an entity class is its entities;
+setting the entity class to *column header* will write the name of the class as the entity's column header.
.. note::
Currently, buddies are fixed and defined only for a small set database items.
@@ -357,12 +353,12 @@ They then act as a pivot header for the data item which is the last non-hidden i
Once checked as pivoted, an item's position column defines a pivot header row instead of output column.
By default a row ends up in the output table only when all mapping items yield some data.
-For example, when exporting object classes and objects, only classes that have objects get written to output.
-However, sometimes it is useful to export 'empty' object classes as well.
+For example, when exporting entity classes and entities, only classes that have entities get written to output.
+However, sometimes it is useful to export 'empty' entity classes as well.
For this purpose a mapping can be set as **nullable** in the *Nullable* column.
-Continuing the example, checking the *Nullable* checkbox for *Objects* would produce an output table with
-all object classes including ones without objects.
-The position where objects would normally be outputted are left empty for those classes.
+Continuing the example, checking the *Nullable* checkbox for *Entities* would produce an output table with
+all entity classes including ones without entities.
+The position where entities would normally be outputted are left empty for those classes.
Besides the *column header* position it is possible to give fixed column headers to items
using the *Header* column in *Mapping specification* dock.
@@ -457,11 +453,11 @@ for these applications.
*Single item*
Writing the item's name to the field filters out all other items.
- For example, to output the object class called 'node' only, write :literal:`node` to the *Filter* field.
+ For example, to output the entity class called 'node' only, write :literal:`node` to the *Filter* field.
*OR operator*
The vertical bar :literal:`|` serves as the OR operator.
- :literal:`node|unit` as a filter for object classes would output classes named 'node' and 'unit'.
+ :literal:`node|unit` as a filter for entity classes would output classes named 'node' and 'unit'.
*Excluding an item*
While perhaps not the most suitable task for regular expressions it is still possible to 'negate' a filter.
diff --git a/docs/source/executing_projects.rst b/docs/source/executing_projects.rst
index 65d142278..272fb0d8c 100644
--- a/docs/source/executing_projects.rst
+++ b/docs/source/executing_projects.rst
@@ -16,12 +16,12 @@ Executing Projects
This section describes how executing a project works and what resources are passed between project
items at execution time. The buttons used to control executions are located in the **Toolbar**'s Execute -section.
-Execution happens by either pressing the **Project** button (|play-all|) to execute the
-whole project, or by pressing the **Selection** button (|play-selected|) to only execute selected items.
+Execution happens by either pressing the *Execute Project* button (|play-all|) to execute the
+whole project, or by pressing the *Execute Selected* button (|play-selected|) to only execute selected items.
Next to these buttons is the **Stop** button (|stop|), which can be used to stop an ongoing execution.
A project consists of project items and connections (yellow arrows) that are visualized on the **Design View**.
You use the project items and the connections to build a **Directed Acyclic Graph (DAG)**, with the project
-items as *nodes* and the connections as *edges*. A DAG is traversed using the **breadth-first-search** algorithm.
+items as *nodes* and the connections as *edges*. The DAG is traversed using the **breadth-first-search** algorithm.
Rules of DAGs:
@@ -69,8 +69,7 @@ The image below shows such a DAG where the items form a loop.
You can also execute only the selected parts of a project by multi-selecting the items you want to
execute and pressing the |play-selected| button in the tool bar. For example, to execute only items
-*b*, *d* and *f*, select the items in **Design View** or in the project item list in **Project** dock
-widget and then press the |play-selected| button.
+*b*, *d* and *f*, select the items in **Design View** |play-selected| button.
.. tip::
You can select multiple project items by holding the **Ctrl** key down and clicking on
diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst
index f3ef05a67..4385ed198 100644
--- a/docs/source/getting_started.rst
+++ b/docs/source/getting_started.rst
@@ -50,9 +50,6 @@ The central element in Spine Toolbox's interface is the **Design View**,
which allows you to visualize and manipulate your project workflow.
In addition to the **Design View** there are a few `dock widgets` that provide additional functionality:
-* **Project** provides a more concise view of your project, including the *Items* that are currently in the
- project, grouped by category: Data Stores, Data Connections, Tools, Views, Importers, Exporters and
- Manipulators.
* **Properties** provides an interface to interact with the currently selected project item.
* **Event Log** shows relevant messages about user performed actions and the status of executions.
* **Console** allows Spine Toolbox to execute Tools written in Python, Julia or GAMS and provides an interface to
@@ -166,8 +163,7 @@ To add a Tool item drag-and-drop the Tool icon |tool_icon| from the **Toolbar**
The **Add Tool** form will popup. Change name of the Tool to 'say hello world', and select 'hello_world' from the
dropdown list just below, and click **Ok**. Now you should see the newly added Tool item as an icon in the
-**Design View**, and also as an entry in the **Project** dock widget, under the 'Tools' category. It
-should look similar to this:
+**Design View**. It should look similar to this:
.. image:: img/getting_started_first_tool_created.png
:align: center
@@ -180,7 +176,9 @@ selected from the dropdown list.
.. note:: The Tool specification is now saved to disk but the project itself is not. Remember to save the project
every once in a while when you are working. You can do this by selecting **File -> Save project**
- from the main window or by pressing **Ctrl+S** when the main window is active.
+ from the main window or by pressing **Ctrl+S** when the main window is active. If the project is in such a state
+ that it has unsaved changes, an asterisk `*` is visible after the project name and path in the upper left corner
+ of the main window.
Executing a Tool
----------------
@@ -220,8 +218,7 @@ To make things more interesting, we will now specify an *input file* for our 'he
**Data Store**, **Exporter**, and **Data Transformer** project items connected to its input.
Open the Tool specification editor for the 'hello world' Tool spec. You can do this for example, by double-clicking
-the 'say hello world' Tool in **Design View**, or by right clicking the 'say hello world' -item in the **Project** dock
-widget and selecting **Specification... -> Edit specification**, or from the **Tool Properties** by clicking the
+the 'say hello world' Tool in **Design View**, or from the **Tool Properties** by clicking the
Tool specification options button (|wrench|) next to the specification and selecting **Edit specification**.
In **Input & Output files** dock widget, click the |plus| button next to the `Input Files` text. A dialog appears,
@@ -268,13 +265,12 @@ Let's add a Data Connection item to our project, so that we're able to pass the
onto the **Design View**.
The *Add Data Connection* form will show up. Type 'pass input txt' in the name field and click **Ok**. The newly
-added Data Connection item is now in the **Design View**, and also as an entry in the **Project** dock widgets items list,
-under the 'Data Connections' category.
+added Data Connection item is now in the **Design View**.
Adding Data Files to a Data Connection
--------------------------------------
-Select the 'pass input txt' Data Connection item to view its properties in the *Properties* dock widget. It should look
+Select the 'pass input txt' Data Connection item to view its properties in the **Properties** dock widget. It should look
similar to this:
.. image:: img/getting_started_dc_properties.png
diff --git a/docs/source/img/add_discharge_water_to_from_node.png b/docs/source/img/add_discharge_water_to_from_node.png
deleted file mode 100644
index b09776e2b..000000000
Binary files a/docs/source/img/add_discharge_water_to_from_node.png and /dev/null differ
diff --git a/docs/source/img/add_electricity_load_from_node.png b/docs/source/img/add_electricity_load_from_node.png
deleted file mode 100644
index 39c75033a..000000000
Binary files a/docs/source/img/add_electricity_load_from_node.png and /dev/null differ
diff --git a/docs/source/img/add_node_commodity.png b/docs/source/img/add_node_commodity.png
deleted file mode 100644
index 32d71c663..000000000
Binary files a/docs/source/img/add_node_commodity.png and /dev/null differ
diff --git a/docs/source/img/add_node_temporal_block.png b/docs/source/img/add_node_temporal_block.png
deleted file mode 100644
index c66250c1a..000000000
Binary files a/docs/source/img/add_node_temporal_block.png and /dev/null differ
diff --git a/docs/source/img/add_power_plant_units.png b/docs/source/img/add_power_plant_units.png
deleted file mode 100644
index 6977692d9..000000000
Binary files a/docs/source/img/add_power_plant_units.png and /dev/null differ
diff --git a/docs/source/img/add_pwr_plant_electricity_to_node.png b/docs/source/img/add_pwr_plant_electricity_to_node.png
deleted file mode 100644
index e8153c1f6..000000000
Binary files a/docs/source/img/add_pwr_plant_electricity_to_node.png and /dev/null differ
diff --git a/docs/source/img/add_pwr_plant_water_from_node.png b/docs/source/img/add_pwr_plant_water_from_node.png
deleted file mode 100644
index c070f423d..000000000
Binary files a/docs/source/img/add_pwr_plant_water_from_node.png and /dev/null differ
diff --git a/docs/source/img/add_pwr_plant_water_to_node.png b/docs/source/img/add_pwr_plant_water_to_node.png
deleted file mode 100644
index 6921e504e..000000000
Binary files a/docs/source/img/add_pwr_plant_water_to_node.png and /dev/null differ
diff --git a/docs/source/img/add_rsrv_water_to_from_node.png b/docs/source/img/add_rsrv_water_to_from_node.png
deleted file mode 100644
index f9c6a5ca0..000000000
Binary files a/docs/source/img/add_rsrv_water_to_from_node.png and /dev/null differ
diff --git a/docs/source/img/add_spillway_water_to_from_node.png b/docs/source/img/add_spillway_water_to_from_node.png
deleted file mode 100644
index 0ae585564..000000000
Binary files a/docs/source/img/add_spillway_water_to_from_node.png and /dev/null differ
diff --git a/docs/source/img/add_storage_commodity.png b/docs/source/img/add_storage_commodity.png
deleted file mode 100644
index 9cbaa6f20..000000000
Binary files a/docs/source/img/add_storage_commodity.png and /dev/null differ
diff --git a/docs/source/img/add_storage_unit.png b/docs/source/img/add_storage_unit.png
deleted file mode 100644
index 80da768bb..000000000
Binary files a/docs/source/img/add_storage_unit.png and /dev/null differ
diff --git a/docs/source/img/case_study_a5_item_connections.png b/docs/source/img/case_study_a5_item_connections.png
deleted file mode 100644
index fd5c2d70c..000000000
Binary files a/docs/source/img/case_study_a5_item_connections.png and /dev/null differ
diff --git a/docs/source/img/case_study_a5_model_parameters.png b/docs/source/img/case_study_a5_model_parameters.png
deleted file mode 100644
index 5ade47477..000000000
Binary files a/docs/source/img/case_study_a5_model_parameters.png and /dev/null differ
diff --git a/docs/source/img/case_study_a5_output.png b/docs/source/img/case_study_a5_output.png
deleted file mode 100644
index a719d7ebb..000000000
Binary files a/docs/source/img/case_study_a5_output.png and /dev/null differ
diff --git a/docs/source/img/case_study_a5_output_electricity_load_unit_flow.png b/docs/source/img/case_study_a5_output_electricity_load_unit_flow.png
deleted file mode 100644
index c9116907b..000000000
Binary files a/docs/source/img/case_study_a5_output_electricity_load_unit_flow.png and /dev/null differ
diff --git a/docs/source/img/case_study_a5_schematic.png b/docs/source/img/case_study_a5_schematic.png
deleted file mode 100644
index 6a3a9f8fa..000000000
Binary files a/docs/source/img/case_study_a5_schematic.png and /dev/null differ
diff --git a/docs/source/img/case_study_a5_spine_db_editor_empty.png b/docs/source/img/case_study_a5_spine_db_editor_empty.png
deleted file mode 100644
index 1beb57bd1..000000000
Binary files a/docs/source/img/case_study_a5_spine_db_editor_empty.png and /dev/null differ
diff --git a/docs/source/img/case_study_a5_spine_opt_tool_properties.png b/docs/source/img/case_study_a5_spine_opt_tool_properties.png
deleted file mode 100644
index 7cfd2d863..000000000
Binary files a/docs/source/img/case_study_a5_spine_opt_tool_properties.png and /dev/null differ
diff --git a/docs/source/img/case_study_a5_spine_opt_tool_properties_cmdline_args.png b/docs/source/img/case_study_a5_spine_opt_tool_properties_cmdline_args.png
deleted file mode 100644
index 410797290..000000000
Binary files a/docs/source/img/case_study_a5_spine_opt_tool_properties_cmdline_args.png and /dev/null differ
diff --git a/docs/source/img/edit_tool_specification_new_program_file.png b/docs/source/img/edit_tool_specification_new_program_file.png
deleted file mode 100644
index 5d5f0f72d..000000000
Binary files a/docs/source/img/edit_tool_specification_new_program_file.png and /dev/null differ
diff --git a/docs/source/img/edit_tool_specification_spine_opt.png b/docs/source/img/edit_tool_specification_spine_opt.png
deleted file mode 100644
index 1f9934261..000000000
Binary files a/docs/source/img/edit_tool_specification_spine_opt.png and /dev/null differ
diff --git a/docs/source/img/gdx_export_parameter_indexing_window_using_existing_domain.png b/docs/source/img/gdx_export_parameter_indexing_window_using_existing_domain.png
deleted file mode 100644
index cb86aae91..000000000
Binary files a/docs/source/img/gdx_export_parameter_indexing_window_using_existing_domain.png and /dev/null differ
diff --git a/docs/source/img/gdx_export_parameter_indexing_window_using_new_domain.png b/docs/source/img/gdx_export_parameter_indexing_window_using_new_domain.png
deleted file mode 100644
index 0ae7bf434..000000000
Binary files a/docs/source/img/gdx_export_parameter_indexing_window_using_new_domain.png and /dev/null differ
diff --git a/docs/source/img/gdx_export_settings_window.png b/docs/source/img/gdx_export_settings_window.png
deleted file mode 100644
index 4fd6426a9..000000000
Binary files a/docs/source/img/gdx_export_settings_window.png and /dev/null differ
diff --git a/docs/source/img/gdx_exporter_properties.png b/docs/source/img/gdx_exporter_properties.png
deleted file mode 100644
index 84122b049..000000000
Binary files a/docs/source/img/gdx_exporter_properties.png and /dev/null differ
diff --git a/docs/source/img/import_editor_mapping_options.png b/docs/source/img/import_editor_mapping_options.png
deleted file mode 100644
index 8dd1bab5e..000000000
Binary files a/docs/source/img/import_editor_mapping_options.png and /dev/null differ
diff --git a/docs/source/img/import_editor_preview_table_mapping_menu.png b/docs/source/img/import_editor_preview_table_mapping_menu.png
index 0c94820eb..c0f6185db 100644
Binary files a/docs/source/img/import_editor_preview_table_mapping_menu.png and b/docs/source/img/import_editor_preview_table_mapping_menu.png differ
diff --git a/docs/source/img/import_editor_window.png b/docs/source/img/import_editor_window.png
index 724d5cc6c..96bb69fed 100644
Binary files a/docs/source/img/import_editor_window.png and b/docs/source/img/import_editor_window.png differ
diff --git a/docs/source/img/importer_spec_editor_anonymous_mode.png b/docs/source/img/importer_spec_editor_anonymous_mode.png
index a5a11d4ac..ba4a52d6e 100644
Binary files a/docs/source/img/importer_spec_editor_anonymous_mode.png and b/docs/source/img/importer_spec_editor_anonymous_mode.png differ
diff --git a/docs/source/img/install_spine_opt_plugin.png b/docs/source/img/install_spine_opt_plugin.png
deleted file mode 100644
index 30f89dd0b..000000000
Binary files a/docs/source/img/install_spine_opt_plugin.png and /dev/null differ
diff --git a/docs/source/img/items_connections.png b/docs/source/img/items_connections.png
deleted file mode 100644
index c8f402194..000000000
Binary files a/docs/source/img/items_connections.png and /dev/null differ
diff --git a/docs/source/img/main_window_new_project_with_project_items.png b/docs/source/img/main_window_new_project_with_project_items.png
deleted file mode 100644
index ed5b17f82..000000000
Binary files a/docs/source/img/main_window_new_project_with_project_items.png and /dev/null differ
diff --git a/docs/source/img/main_window_no_project.png b/docs/source/img/main_window_no_project.png
index e8e616911..439c1d88d 100644
Binary files a/docs/source/img/main_window_no_project.png and b/docs/source/img/main_window_no_project.png differ
diff --git a/docs/source/img/main_window_spineopt_load_template_ready.png b/docs/source/img/main_window_spineopt_load_template_ready.png
deleted file mode 100644
index 96eb2ac5c..000000000
Binary files a/docs/source/img/main_window_spineopt_load_template_ready.png and /dev/null differ
diff --git a/docs/source/img/partial_dag_input_datastore-tool-output_data_store.png b/docs/source/img/partial_dag_input_datastore-tool-output_data_store.png
deleted file mode 100644
index 46ae6f446..000000000
Binary files a/docs/source/img/partial_dag_input_datastore-tool-output_data_store.png and /dev/null differ
diff --git a/docs/source/img/pass_input_txt_dc_and_say_hello_world_tool.png b/docs/source/img/pass_input_txt_dc_and_say_hello_world_tool.png
deleted file mode 100644
index bf846c289..000000000
Binary files a/docs/source/img/pass_input_txt_dc_and_say_hello_world_tool.png and /dev/null differ
diff --git a/docs/source/img/pass_input_txt_dc_properties.png b/docs/source/img/pass_input_txt_dc_properties.png
deleted file mode 100644
index c535fa60f..000000000
Binary files a/docs/source/img/pass_input_txt_dc_properties.png and /dev/null differ
diff --git a/docs/source/img/pass_input_txt_dc_properties_with_file.png b/docs/source/img/pass_input_txt_dc_properties_with_file.png
deleted file mode 100644
index 2d51e67be..000000000
Binary files a/docs/source/img/pass_input_txt_dc_properties_with_file.png and /dev/null differ
diff --git a/docs/source/img/project_item_icons/database.svg b/docs/source/img/project_item_icons/database.svg
deleted file mode 100644
index a6e4982bc..000000000
--- a/docs/source/img/project_item_icons/database.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/docs/source/img/project_item_icons/hammer.svg b/docs/source/img/project_item_icons/hammer.svg
deleted file mode 100644
index 78df6b490..000000000
--- a/docs/source/img/project_item_icons/hammer.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/docs/source/img/project_item_icons/play-circle.svg b/docs/source/img/project_item_icons/play-circle.svg
deleted file mode 100644
index ea1039704..000000000
--- a/docs/source/img/project_item_icons/play-circle.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/docs/source/img/pypsa_dc.png b/docs/source/img/pypsa_dc.png
deleted file mode 100644
index e32941440..000000000
Binary files a/docs/source/img/pypsa_dc.png and /dev/null differ
diff --git a/docs/source/img/release06_main_window_empty.png b/docs/source/img/release06_main_window_empty.png
deleted file mode 100644
index d866bf8c0..000000000
Binary files a/docs/source/img/release06_main_window_empty.png and /dev/null differ
diff --git a/docs/source/img/settings_db_editor.png b/docs/source/img/settings_db_editor.png
index 093b7788b..6620df7d1 100644
Binary files a/docs/source/img/settings_db_editor.png and b/docs/source/img/settings_db_editor.png differ
diff --git a/docs/source/img/settings_project.png b/docs/source/img/settings_project.png
deleted file mode 100644
index 5ede2d07f..000000000
Binary files a/docs/source/img/settings_project.png and /dev/null differ
diff --git a/docs/source/img/settings_tools_default.png b/docs/source/img/settings_tools_default.png
index c87bf15fb..a48971213 100644
Binary files a/docs/source/img/settings_tools_default.png and b/docs/source/img/settings_tools_default.png differ
diff --git a/docs/source/img/settings_tools_filled_for_spineopt_github.png b/docs/source/img/settings_tools_filled_for_spineopt_github.png
deleted file mode 100644
index 068777a7e..000000000
Binary files a/docs/source/img/settings_tools_filled_for_spineopt_github.png and /dev/null differ
diff --git a/docs/source/img/simple_system_add_nodes.png b/docs/source/img/simple_system_add_nodes.png
deleted file mode 100644
index 67857eacf..000000000
Binary files a/docs/source/img/simple_system_add_nodes.png and /dev/null differ
diff --git a/docs/source/img/simple_system_add_output.png b/docs/source/img/simple_system_add_output.png
deleted file mode 100644
index 90bc94bec..000000000
Binary files a/docs/source/img/simple_system_add_output.png and /dev/null differ
diff --git a/docs/source/img/simple_system_add_report__output_relationships.png b/docs/source/img/simple_system_add_report__output_relationships.png
deleted file mode 100644
index ba81502d3..000000000
Binary files a/docs/source/img/simple_system_add_report__output_relationships.png and /dev/null differ
diff --git a/docs/source/img/simple_system_add_unit__from_node_relationships.png b/docs/source/img/simple_system_add_unit__from_node_relationships.png
deleted file mode 100644
index 1cb905028..000000000
Binary files a/docs/source/img/simple_system_add_unit__from_node_relationships.png and /dev/null differ
diff --git a/docs/source/img/simple_system_add_unit__to_node_relationships.png b/docs/source/img/simple_system_add_unit__to_node_relationships.png
deleted file mode 100644
index 6ce50d349..000000000
Binary files a/docs/source/img/simple_system_add_unit__to_node_relationships.png and /dev/null differ
diff --git a/docs/source/img/simple_system_add_units.png b/docs/source/img/simple_system_add_units.png
deleted file mode 100644
index 884d788fc..000000000
Binary files a/docs/source/img/simple_system_add_units.png and /dev/null differ
diff --git a/docs/source/img/simple_system_cmdline_args.png b/docs/source/img/simple_system_cmdline_args.png
deleted file mode 100644
index cd86391f7..000000000
Binary files a/docs/source/img/simple_system_cmdline_args.png and /dev/null differ
diff --git a/docs/source/img/simple_system_electricity_demand.png b/docs/source/img/simple_system_electricity_demand.png
deleted file mode 100644
index 053809be8..000000000
Binary files a/docs/source/img/simple_system_electricity_demand.png and /dev/null differ
diff --git a/docs/source/img/simple_system_fix_ratio_out_in_unit_flow.png b/docs/source/img/simple_system_fix_ratio_out_in_unit_flow.png
deleted file mode 100644
index e4ff0cbf0..000000000
Binary files a/docs/source/img/simple_system_fix_ratio_out_in_unit_flow.png and /dev/null differ
diff --git a/docs/source/img/simple_system_fuel_balance_type.png b/docs/source/img/simple_system_fuel_balance_type.png
deleted file mode 100644
index 2c74f1b40..000000000
Binary files a/docs/source/img/simple_system_fuel_balance_type.png and /dev/null differ
diff --git a/docs/source/img/simple_system_item_connections.png b/docs/source/img/simple_system_item_connections.png
deleted file mode 100644
index 709558114..000000000
Binary files a/docs/source/img/simple_system_item_connections.png and /dev/null differ
diff --git a/docs/source/img/simple_system_power_plant_a_capacity.png b/docs/source/img/simple_system_power_plant_a_capacity.png
deleted file mode 100644
index 577645a1f..000000000
Binary files a/docs/source/img/simple_system_power_plant_a_capacity.png and /dev/null differ
diff --git a/docs/source/img/simple_system_power_plant_a_vom_cost.png b/docs/source/img/simple_system_power_plant_a_vom_cost.png
deleted file mode 100644
index cce73b1f5..000000000
Binary files a/docs/source/img/simple_system_power_plant_a_vom_cost.png and /dev/null differ
diff --git a/docs/source/img/simple_system_power_plant_b_capacity.png b/docs/source/img/simple_system_power_plant_b_capacity.png
deleted file mode 100644
index dfc1f4b1f..000000000
Binary files a/docs/source/img/simple_system_power_plant_b_capacity.png and /dev/null differ
diff --git a/docs/source/img/simple_system_power_plant_b_vom_cost.png b/docs/source/img/simple_system_power_plant_b_vom_cost.png
deleted file mode 100644
index 3b807e5f4..000000000
Binary files a/docs/source/img/simple_system_power_plant_b_vom_cost.png and /dev/null differ
diff --git a/docs/source/img/simple_system_results_pivot_table.png b/docs/source/img/simple_system_results_pivot_table.png
deleted file mode 100644
index ebc979499..000000000
Binary files a/docs/source/img/simple_system_results_pivot_table.png and /dev/null differ
diff --git a/docs/source/img/simple_system_schematic.png b/docs/source/img/simple_system_schematic.png
deleted file mode 100644
index 0839f8bb8..000000000
Binary files a/docs/source/img/simple_system_schematic.png and /dev/null differ
diff --git a/docs/source/img/spine_datapackage_editor_pypsa.png b/docs/source/img/spine_datapackage_editor_pypsa.png
deleted file mode 100644
index 09c4ed631..000000000
Binary files a/docs/source/img/spine_datapackage_editor_pypsa.png and /dev/null differ
diff --git a/docs/source/img/spine_opt_plugin_tool_bar.png b/docs/source/img/spine_opt_plugin_tool_bar.png
deleted file mode 100644
index 233734632..000000000
Binary files a/docs/source/img/spine_opt_plugin_tool_bar.png and /dev/null differ
diff --git a/docs/source/img/tabular_view_edit_data.png b/docs/source/img/tabular_view_edit_data.png
deleted file mode 100644
index cd232ad8a..000000000
Binary files a/docs/source/img/tabular_view_edit_data.png and /dev/null differ
diff --git a/docs/source/img/tabular_view_edit_index.png b/docs/source/img/tabular_view_edit_index.png
deleted file mode 100644
index a96875b06..000000000
Binary files a/docs/source/img/tabular_view_edit_index.png and /dev/null differ
diff --git a/docs/source/img/tree_view_table_parameter_tools.png b/docs/source/img/tree_view_table_parameter_tools.png
deleted file mode 100644
index fac75ec20..000000000
Binary files a/docs/source/img/tree_view_table_parameter_tools.png and /dev/null differ
diff --git a/docs/source/img/tree_view_table_tab.png b/docs/source/img/tree_view_table_tab.png
deleted file mode 100644
index a4d929f93..000000000
Binary files a/docs/source/img/tree_view_table_tab.png and /dev/null differ
diff --git a/docs/source/img/two_hydro.png b/docs/source/img/two_hydro.png
deleted file mode 100644
index ac193a0e6..000000000
Binary files a/docs/source/img/two_hydro.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_commodities.png b/docs/source/img/two_hydro_commodities.png
deleted file mode 100644
index a9ee3e0a6..000000000
Binary files a/docs/source/img/two_hydro_commodities.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_connection_from_node.png b/docs/source/img/two_hydro_connection_from_node.png
deleted file mode 100644
index 962d27b8e..000000000
Binary files a/docs/source/img/two_hydro_connection_from_node.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_connection_node_node.png b/docs/source/img/two_hydro_connection_node_node.png
deleted file mode 100644
index 5d22c430c..000000000
Binary files a/docs/source/img/two_hydro_connection_node_node.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_connection_node_node_parameters.png b/docs/source/img/two_hydro_connection_node_node_parameters.png
deleted file mode 100644
index e4881c7ed..000000000
Binary files a/docs/source/img/two_hydro_connection_node_node_parameters.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_connections.png b/docs/source/img/two_hydro_connections.png
deleted file mode 100644
index ad26202dc..000000000
Binary files a/docs/source/img/two_hydro_connections.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_fix_node_state.png b/docs/source/img/two_hydro_fix_node_state.png
deleted file mode 100644
index b5d25e9e9..000000000
Binary files a/docs/source/img/two_hydro_fix_node_state.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_max_stored_water_unit_node_node.png b/docs/source/img/two_hydro_max_stored_water_unit_node_node.png
deleted file mode 100644
index 2e1961843..000000000
Binary files a/docs/source/img/two_hydro_max_stored_water_unit_node_node.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_max_stored_water_unit_values.png b/docs/source/img/two_hydro_max_stored_water_unit_values.png
deleted file mode 100644
index 1a43f8021..000000000
Binary files a/docs/source/img/two_hydro_max_stored_water_unit_values.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_min_spill_unit_node_node.png b/docs/source/img/two_hydro_min_spill_unit_node_node.png
deleted file mode 100644
index 02d37c11d..000000000
Binary files a/docs/source/img/two_hydro_min_spill_unit_node_node.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_model_parameters.png b/docs/source/img/two_hydro_model_parameters.png
deleted file mode 100644
index 16e0510ec..000000000
Binary files a/docs/source/img/two_hydro_model_parameters.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_node_commodities.png b/docs/source/img/two_hydro_node_commodities.png
deleted file mode 100644
index d283a62f4..000000000
Binary files a/docs/source/img/two_hydro_node_commodities.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_node_parameters.png b/docs/source/img/two_hydro_node_parameters.png
deleted file mode 100644
index 901225ee6..000000000
Binary files a/docs/source/img/two_hydro_node_parameters.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_nodes.png b/docs/source/img/two_hydro_nodes.png
deleted file mode 100644
index 7793302a6..000000000
Binary files a/docs/source/img/two_hydro_nodes.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_report.png b/docs/source/img/two_hydro_report.png
deleted file mode 100644
index 401e2ad5f..000000000
Binary files a/docs/source/img/two_hydro_report.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_results_discharge.png b/docs/source/img/two_hydro_results_discharge.png
deleted file mode 100644
index 9c73b51f1..000000000
Binary files a/docs/source/img/two_hydro_results_discharge.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_results_electricity.png b/docs/source/img/two_hydro_results_electricity.png
deleted file mode 100644
index 7a64abea8..000000000
Binary files a/docs/source/img/two_hydro_results_electricity.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_temporal_block.png b/docs/source/img/two_hydro_temporal_block.png
deleted file mode 100644
index a48ee5d68..000000000
Binary files a/docs/source/img/two_hydro_temporal_block.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_unit_from_node.png b/docs/source/img/two_hydro_unit_from_node.png
deleted file mode 100644
index 8279b3836..000000000
Binary files a/docs/source/img/two_hydro_unit_from_node.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_unit_from_node_parameters.png b/docs/source/img/two_hydro_unit_from_node_parameters.png
deleted file mode 100644
index 819431e01..000000000
Binary files a/docs/source/img/two_hydro_unit_from_node_parameters.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_unit_node_node.png b/docs/source/img/two_hydro_unit_node_node.png
deleted file mode 100644
index 54745c4a1..000000000
Binary files a/docs/source/img/two_hydro_unit_node_node.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_unit_node_node_parameters.png b/docs/source/img/two_hydro_unit_node_node_parameters.png
deleted file mode 100644
index 59651de2d..000000000
Binary files a/docs/source/img/two_hydro_unit_node_node_parameters.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_unit_to_node.png b/docs/source/img/two_hydro_unit_to_node.png
deleted file mode 100644
index 2b3960043..000000000
Binary files a/docs/source/img/two_hydro_unit_to_node.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_unit_to_node_parameters.png b/docs/source/img/two_hydro_unit_to_node_parameters.png
deleted file mode 100644
index 3d9e8da0c..000000000
Binary files a/docs/source/img/two_hydro_unit_to_node_parameters.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_units.png b/docs/source/img/two_hydro_units.png
deleted file mode 100644
index cae2504fc..000000000
Binary files a/docs/source/img/two_hydro_units.png and /dev/null differ
diff --git a/docs/source/img/two_hydro_vom_cost.png b/docs/source/img/two_hydro_vom_cost.png
deleted file mode 100644
index 2898d1810..000000000
Binary files a/docs/source/img/two_hydro_vom_cost.png and /dev/null differ
diff --git a/docs/source/img/using_input_output_files_in_tool_scripts.png b/docs/source/img/using_input_output_files_in_tool_scripts.png
new file mode 100644
index 000000000..57b1ae8e5
Binary files /dev/null and b/docs/source/img/using_input_output_files_in_tool_scripts.png differ
diff --git a/docs/source/main_window.rst b/docs/source/main_window.rst
index 911e8e4ae..1da177f7d 100644
--- a/docs/source/main_window.rst
+++ b/docs/source/main_window.rst
@@ -23,13 +23,12 @@ The first time you start the application you will see the main window like this.
.. image:: img/main_window_no_project.png
:align: center
-The application main window contains four dock widgets (**Project**, **Properties**, **Event Log**, **Console**), a
-**Toolbar**, a **Design View**, and a menu bar with **File**, **Edit**, **View**, **Plugins**, **Consoles**, **Server**
-and **Help** menus. The **Project** dock widget contains a list of project items in the project. The **Properties**
-dock widget shows the properties of the selected project item. **Event Log** shows messages based on user actions and
-item executions. It shows messages from processes that are spawned by the application, i.e. it shows the stdout and
-stderr streams of GAMS and executable programs. Also things like whether an item's execution was successful and
-when the project or an item specification is saved are shown.
+The application main window contains four dock widgets (**Design View**, **Properties**, **Event Log**, **Console**), a
+**Toolbar**, and a menu bar with **File**, **Edit**, **View**, **Plugins**, **Consoles**, **Server**
+and **Help** menus. The **Properties** dock widget shows the properties of the selected project item. **Event Log**
+shows messages based on user actions and item executions. It shows messages from processes that are spawned by the
+application, i.e. it shows the stdout and stderr streams of executable programs. In addition, it displays messages
+related to item's execution.
**Console** provides Julia and Python consoles that can be interacted with. What kind of console
is shown depends on the Tool type of the specific Tool. Only an item that is currently executing or has already
@@ -49,8 +48,8 @@ contains a link to this documentation as well as various tidbits about Spine Too
The **Items** section of the **Toolbar** contains the available
:ref:`project item ` types.
-The **Execute** section contains icons that control the execution of the items in the **Design view** where you build your project.
-The |play-all| button executes all Directed Acyclic Graphs (DAG) in the project in a row. The |play-selected| button
+The **Execute** section contains icons that control the execution of the items in the **Design view**. The |play-all|
+button executes all workflows in the project in parallel. The |play-selected| button
executes the selected project items only. The |stop| button terminates the execution (if running).
You can add a new project item to your project by pointing your mouse cursor on any of the draggable items
@@ -59,19 +58,9 @@ After this you will be presented a dialog, which asks you to fill in basic infor
item (name, description, etc.).
The main window is very customizable so you can e.g. close the dock widgets that you do not need, rearrange the order
-of the dock widgets by dragging them around and/or resize the views to fit your needs and display size or resolution. You can find more ways to
-customize the visual elements of Spine Toolbox in the :ref:`settings `.
+of the dock widgets by dragging them around and/or resize the views to fit your needs and display size or
+resolution. You can find more ways to customize the visual elements of Spine Toolbox in the :ref:`settings `.
.. note:: If you want to restore all dock widgets to their default place use the menu item
**View -> Dock Widgets -> Restore Dock Widgets**.
This will show all hidden dock widgets and restore them to the main window.
-
-Below is an example on how you can customize the main window. In the picture, a user has created a project `New
-Project` and created one project item from each of the eight categories. A Data Connection called `Data files`,
-a Data Store called `Database`, a Data Transformer called `Data Transformer`, an Exporter called `Exporter`,
-an Importer called `Importer`, a Merger called `Merger`, a Tool called `Julia model` and a View called `View`. The
-project items are also listed in the **Project** dock widget. Some of the dock widgets have also been moved from
-their original places.
-
-.. image:: img/main_window_new_project_with_project_items.png
- :align: center
diff --git a/docs/source/parameter_value_editor.rst b/docs/source/parameter_value_editor.rst
index 3253527ed..6ab07b97e 100644
--- a/docs/source/parameter_value_editor.rst
+++ b/docs/source/parameter_value_editor.rst
@@ -4,7 +4,7 @@
Parameter Value Editor
**********************
-Parameter value editor is used to edit object and relationship parameter values
+Parameter value editor is used to edit entity parameter values
such as time series, time patterns or durations.
It can also convert between different value types, e.g. from a time series to a time pattern.
diff --git a/docs/source/project_items.rst b/docs/source/project_items.rst
index 35acfc278..051c54c0b 100644
--- a/docs/source/project_items.rst
+++ b/docs/source/project_items.rst
@@ -49,7 +49,6 @@ Those interested in looking under the hood can check the :ref:`Project item deve
Project Item Properties
-----------------------
-
Each project item has its own set of `properties`. You can view and edit them by selecting a project
item in the **Design View**. The properties are displayed in the **Properties** dock widget on the main
window. Project item properties are saved into the project save file (``project.json``), which can be
diff --git a/docs/source/setting_up.rst b/docs/source/setting_up.rst
index 56f731a51..9ffd5d244 100644
--- a/docs/source/setting_up.rst
+++ b/docs/source/setting_up.rst
@@ -23,7 +23,7 @@ them up. To get started with **SpineOpt.jl**, see :ref:`How to Set up SpineOpt.j
Python and Julia Tools can be executed either in an embedded *Basic Console* or in a *Jupyter Console*. GAMS Tools
are executed in a sub-process. Executable Tools (external programs) are executed in a shell or by running the
-executable file straight. You can also make a Tool that executes a shell command by creating an *Executable* Tool
+executable file directly. You can also make a Tool that executes a shell command by creating an *Executable* Tool
Spec in **Tool Specification Editor**, entering the shell command to the *Command* line edit and then selecting the
Shell for this Tool.
@@ -46,17 +46,16 @@ Spec. Editor**. Then drag the Python Tool Spec into the **Design View**, and pre
Julia
*****
-To execute Julia Tools in the Basic Console, first install Julia (v1.6 or later)
+To execute Julia Tools in the Basic Console, first install Julia (v1.6 or later recommended)
`from here `_ and add `/bin` to your PATH environment variable
(if not done automatically by the installer). Then go to the *Tools* page in **File -> Settings** and make sure that
the Basic Console radio button is selected in the Julia group. If Julia is in your PATH, the Julia executable line
edit should show the path as (grey) placeholder text. If you want to use another Julia on your system, you can change
the path in the line edit. You can also set a Julia Project below the Julia executable line edit.
-.. note:: The Julia settings are *global* application settings. All Julia Tools are executed with the settings
- selected on the *Tools* page in **File -> Settings**. In upcoming versions, the Julia settings will be consistent
- with the Python settings, in a way that you can select a specific Julia executable and Julia project for each
- Julia Tool Spec separately.
+.. note:: The Julia settings on the *Tools* page in **File -> Settings** are the *default* settings for new Julia
+ Tool Specs. You can select a different Julia executable & project for each Julia Tool Spec separately using the
+ **Tool Specification Editor**.
Jupyter Consoles
----------------
@@ -79,11 +78,11 @@ specific instructions for creating kernel specs for Conda environments below.
.. image:: img/python_jupyter_console_selected.png
:align: center
-Once the **ipykernel** package is installed, the wizard runs the **ipykernel** install command, which creates the
-kernel specs directory on your system. You can quickly open the kernel spec directory from the
-**Select Python Kernel...** combo box's context-menu (mouse right-click menu). Once the process finishes,
-click Close, and the newly created kernel spec (*python39* in this case) should be selected automatically.
-Click *Ok* to close the **Settings** widget and to save your selections.
+Once the **ipykernel** package is installed, the wizard runs the **ipykernel install** command, which creates the
+kernel specs directory on your system. Once the process finishes, click *Close*, and the newly created kernel spec
+(*python39* in this case) should be selected automatically. For convenience, there is a context-menu (mouse
+right-click menu) in the **Select Python Kernel...** combo box that opens the the kernel spec directory in your
+file browser. Click *Ok* to close the **Settings** widget and to save your selections.
.. image:: img/python_kernel_specification_creator.png
:align: center
@@ -101,7 +100,10 @@ And to install the kernel specs run::
python -m ipykernel install --user --name python39 --display-name python39_spinetoolbox
-Make sure to use the ``--user`` argument to make sure that the kernel specs are discoverable by Spine Toolbox.
+Make sure to use the ``--user`` argument to in order to make the kernel specs discoverable by Spine Toolbox.
+
+.. important:: If you want to have access to `spinedb_api`, you need to install it manually for the Python you
+ select here.
.. note::
Clicking **Make Python Kernel** button when the kernel specs have already been installed, does NOT open the
@@ -112,9 +114,6 @@ Make sure to use the ``--user`` argument to make sure that the kernel specs are
This means, that if you still have some old Python 2.7 scripts lying around, you can incorporate those into
a Spine Toolbox project workflow and execute them without modifications.
-.. important:: If you want to have access to `spinedb_api`, you need to install it manually for the Python you
- select here.
-
Julia
*****
To use the Jupyter Console with Julia Tools, go to the *Tools* page in **File -> Settings** and select the
diff --git a/docs/source/settings.rst b/docs/source/settings.rst
index 2d0e34834..c4731d9c6 100644
--- a/docs/source/settings.rst
+++ b/docs/source/settings.rst
@@ -3,6 +3,9 @@
.. |open-folder| image:: ../../spinetoolbox/ui/resources/menu_icons/folder-open-solid.svg
:width: 16
+.. |share| image:: ../../spinetoolbox/ui/resources/share.svg
+ :width: 16
+
.. _Settings:
********
@@ -110,19 +113,23 @@ Choose the settings on how Julia Tools are executed.
- **Select Julia kernel... drop-down menu** Select the kernel you want to launch in Jupyter Console.
+.. note:: The Julia settings above are **the default settings for new Julia Tool Specs**. You can select a
+ specific Julia executable & project or Julia kernel for each Tool Spec separately using the
+ **Tool Specification Editor**.
+
- **Make Julia Kernel** Clicking this button makes a new kernel based on the selected *Julia executable*, and *Julia
project*. The progress of the operation is shown in another dialog. Installing a Julia kernel requires the **IJulia**
package which will be installed to the selected *Julia project*. After **IJulia** has been installed, the kernel is
installed. This process can take a couple of minutes to finish.
+- |share| This button sets **all Julia Tools** execution settings in the current project to defaults. This
+ operation is irreversible because the project will be saved afterwards.
+
- **Install Julia** Installs the latest Julia on your system using the **jill** package.
- **Add/Update SpineOpt** Installs the latest compatible **SpineOpt** to the selected Julia project. If the selected
*Julia project* already has SpineOpt, it is upgraded if there is a new version available.
-.. note:: These Julia settings are *global* application settings. All Julia Tools are executed with the settings
- selected here.
-
Settings in the **Python** group:
Choose the settings on how Python Tools are executed.
@@ -137,13 +144,17 @@ Choose the settings on how Python Tools are executed.
- **Select Python kernel... drop-down menu** Select the kernel you want to launch in Jupyter Console.
+.. note:: The Python settings above are **the default settings for new Python Tool Specs**. You can select a
+ specific Python executable or Python kernel for each Python Tool Spec separately using the
+ **Tool Specification Editor**.
+
- **Make Python Kernel** clicking this button makes a new kernel based on the selected *Python executable*. The
progress of the operation is shown in another dialog. Installing a Python kernel (actually IPython kernel)
requires the **ipykernel** package which will be installed to the selected *Python executables*. After
**ipykernel** has been installed, the kernel is installed. This process can take a couple of minutes to finish.
-.. note:: These Python settings are just the default settings *for new Python Tool Specs*. You can select a
- specific Python kernel for each Python Tool Spec separately using the **Tool Specification Editor**.
+- |share| This button sets **all Python Tools** execution settings in the current project to defaults. This
+ operation is irreversible because the project will be saved afterwards.
Settings in the **Conda** group:
@@ -152,6 +163,8 @@ Settings in the **Conda** group:
See :ref:`Setting up Consoles and External Tools` for more information and examples.
+.. _DB editor settings:
+
Db editor Settings
------------------
@@ -161,6 +174,8 @@ Db editor Settings
This tab contains settings for the Spine Database editor. The same settings can be accessed directly
from the Database editor itself.
+Settings in the **General** group:
+
- **Commit session before closing** This checkbox controls what happens when you close a
database editor which has uncommitted changes. When this is unchecked, all changes are discarded without
notice. When this is partially checked (default), a message box warning you about uncommitted
@@ -170,28 +185,38 @@ from the Database editor itself.
- **Show undo notifications** Checking this will show undo notification boxes in the editor
every time something undoable happens. Unchecking hides the notifications.
+Settings in the **Entity tree** group:
+
- **Sticky selection in entity trees** Controls how selecting items in Spine database editor's
- Object and Relationships trees using the left mouse button works.
+ **Entity Tree** using the left mouse button works.
If checked, multiple selection is enabled and pressing **Ctrl** enables single selection.
If unchecked, single selection is enabled and pressing **Ctrl** enables multiple selection.
-- **Move relationships along with objects in Entity graph** This controls how relationship nodes
- behave on the Graph view when object nodes are moved around.
- If checked, connected relationship nodes move along with the object node.
- If unchecked, connected relationship nodes remain where they are when objects nodes are moved.
+Settings in the **Entity graph** group:
+
+- **Auto-expand entities** This checkbox controls which N-D entities are automatically shown on the Graph view.
+ If checked, all N-D entities that are related to the selection are included automatically.
+ If unchecked, only N-D entities that have all the elements visible in the graph are shown automatically.
-- **Smooth Entity graph zoom** Checking this enables smooth zoom on the Graph view.
+- **Merge databases** If checked, Graph view will combine all databases
+ that are open on the same table into a single graph if they contains common entity nodes.
+ If unchecked, a separate graph will be drawn for each database.
-- **Smooth Entity graph rotation** Checking this enables smooth rotation on the Graph view.
+- **Snap entities to grid** Makes it so that the placement of the entities can’t be arbitrary anymore
+ but instead they can only lay on a grid.
-- **Auto-expand objects by default in Entity graph** This checkbox controls which relationship
- nodes to show on the Graph view.
- If checked, all relationships that contain a visible object node are included.
- If unchecked, relationship nodes are included only if all their objects are show on the Graph view.
+- **Smooth zoom** Checking this enables smooth zoom on the Graph view.
-- **Merge databases by default in Entity graph** If checked, Graph view will combine all databases
- that are open on the same table into a single graph if they contains common object nodes.
- If unchecked, a separate graph will be drawn for each database.
+- **Smooth rotation** Checking this enables smooth rotation on the Graph view.
+
+- **Max. entity dimension count** Defines a cutoff for the number of dimensions an entity can have and still be drawn.
+
+- **Number of build iterations** Defines the maximum numbers of iterations the layout generation algorithm can make.
+
+- **Minimum distance between nodes (%)** Used for setting the ideal distance between entities in the graph.
+
+- **Decay rate of attraction with distance** The higher this number, the lesser the attraction between
+ distant vertices when drawing the graph.
Spec. editor Settings
---------------------
diff --git a/docs/source/spine_db_editor/adding_data.rst b/docs/source/spine_db_editor/adding_data.rst
index d8f52d226..7478c28d6 100644
--- a/docs/source/spine_db_editor/adding_data.rst
+++ b/docs/source/spine_db_editor/adding_data.rst
@@ -1,182 +1,180 @@
+.. |add| image:: ../../../spinetoolbox/ui/resources/menu_icons/cube_plus.svg
+ :width: 16
+.. |remove| image:: ../../../spinetoolbox/ui/resources/menu_icons/cube_minus.svg
+ :width: 16
+
Adding data
-----------
-This section describes the available tools to add new data.
+This section describes the available tools to add new data. Note that after adding
+data to a Spine Database, it still needs to be committed in order for the changes
+to take effect beyond just the current session in the Spine Database Editor. More
+information about this in the chapter :ref:`committing_and_history`.
.. contents::
:local:
-
-Adding object classes
+Adding entity classes
=====================
-From *Object tree*
-~~~~~~~~~~~~~~~~~~
+From **Entity Tree**
+~~~~~~~~~~~~~~~~~~~~
-Right-click on the root item in *Object tree* to display the context menu, and select **Add object classes**.
+Right-click on the root item in **Entity Tree** to display the context menu, and select **Add entity classes**.
-The *Add object classes* dialog will pop up:
+The *Add entity classes* dialog will pop up:
-.. image:: img/add_object_classes_dialog.png
+.. image:: img/add_entity_classes_dialog.png
:align: center
-Enter the names of the classes you want to add under the *object class name* column.
-Optionally, you can enter a description for each class under the *description* column.
-To select icons for your classes, double click on the corresponding cell under the *display icon* column.
-Finally, select the databases where you want to add the classes under *databases*.
-When you're ready, press **Ok**.
+Select the number of dimensions using the spinbox at the top. The amount of dimensions determines the number
+of dimension names that need to be selected. With 0-dimensional classes, like in the image above, only the name
+of the created entity class is required. The class's name is to be entered below the header **entity class name**.
+In other cases like in the image below, in addition to the created classes name, also the classes making up the new
+class need to be selected:
-Adding objects
-==============
-
-From *Object tree* or *Entity graph*
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-Right-click on an object class item in *Object tree*, or on an empty space in the *Entity graph*,
-and select **Add objects** from the context menu.
+.. image:: img/add_entity_classes_dialog_2D.png
+ :align: center
-The *Add objects* dialog will pop up:
+They need to be filled under the headers **dimension name (1)** through **dimension name (N)** where N is the
+selected dimension. Note that because of this, there needs to be at least N entity classes already defined in the
+database when creating an N dimensional entity class. To display a list of available classes, start typing or double
+click on the cell below the header. Optionally, you can enter a description for each class under the **description**
+header. Double clicking the cell under the header **display icon** will open up the icon editor where the visual
+representation of the class can be modified:
-.. image:: img/add_objects_dialog.png
+.. image:: img/entity_icon_editor.png
:align: center
-Enter the names of the object classes under *object class name*, and the names of the objects under *object name*.
-To display a list of available classes, start typing or double click on any cell under the *object class name* column.
-Optionally, you can enter a description for each object under the *description* column.
-Finally, select the databases where you want to add the objects under *databases*.
-When you're ready, press **Ok**.
+The boolean value of **active by default** will determine whether the entities created under the created class
+will have the value under entity alternative set as true or false by default. Finally, select the databases where
+you want to add the entity classes under **databases**.
-From *Pivot table*
-~~~~~~~~~~~~~~~~~~
-To add an object to a specific class, bring the class to *Pivot table* using any input type
-(see :ref:`using_pivot_table_and_frozen_table`).
-Then, enter the object name in the last cell of the header corresponding to that class.
+Multiple additions can be at once. When some information is inserted into the preceding row, a new empty row will
+appear in the dialog where a new class with the same dimensions can be defined. Delete entire rows from the dialog
+with **Remove selected rows**. When you're ready, press **Ok** to make the additions.
-Duplicating objects
-~~~~~~~~~~~~~~~~~~~
-To duplicate an existing object with all its relationships and parameter values,
-right-click over the corresponding object item in *Object tree* to display the context menu,
-and select **Duplicate object**. Enter a name for the duplicate and press **Ok**.
+.. tip:: All the *Add...* dialogs support pasting tabular (spreadsheet) data from the clipboard.
+ Just select any cell in the table and press **Ctrl+V**.
+ If needed, the table will grow to accommodate the exceeding data.
+ To paste data on multiple cells, select all the cells you want to paste on and press **Ctrl+V**.
+Adding entities
+===============
-Adding object groups
-====================
+From **Entity Tree** or **Graph View**
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-Right-click on an object class item in *Object tree*,
-and select **Add object group** from the context menu.
+Right-click on the root item in **Entity Tree** and select **Add entities**, or click on an empty space
+in the **Graph View** and select **Add entities...** from the context menu.
-The *Add object group* dialog will pop up:
+This will open up the **Add entities** dialog:
-.. image:: img/add_object_group_dialog.png
+.. image:: img/add_entities_dialog.png
:align: center
-Enter the name of the group, and select the database where you want the group to be created.
-Select the member objects under *Non members*, and press the button in the middle that has a plus sign.
-Multiple selection works.
+Select the class where you want to add the entities from **Entity class**. It will list all of the entity classes.
+To narrow down the list, instead of opening the dialog from the root item, open it from a specific entity class item
+in the **Entity Tree**. This way the class will be preselected from the list and the list will overall only contain
+other classes that are relevant to the selected class.
-When you're happy with your selections, press **Ok** to add the group to the database.
+Enter the names of the entities under **entity name**. Finally, select the databases where you want to add the
+entities under **databases**. When you're ready, press **Ok**. Rows can once again be deleted with the
+**Remove selected rows** -button.
+With N-D entity classes, the elements need to be specified. After defining the elements the entity's name can be
+modified:
-Adding relationship classes
-===========================
+.. image:: img/add_entities_dialog_2D.png
+ :align: center
-From *Object tree* or *Relationship tree*
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-Right-click on an object class item in *Object tree*, or on the root item in *Relationship tree*,
-and select **Add relationship classes** from the context menu.
+New entities for an existing N-D entity class can also be created easily from the **Graph view**.
+Make sure all the entities you want as members in the new entity are in the graph.
+To start the new N-D entity, either double click on one of the entity items in the graph,
+or right click on it to display the context menu, and choose the class from **Connect entities**.
+After selecting the class the mouse cursor will adopt a cross-hairs shape.
+
+When hovering over a entity item, the cursor will aid by indicating an entity that can't be a
+member by turning into a red restriction -sign. When nearly all of the selections made and only the
+last member needs to be selected, the cursor will turn into a green checkmark when hovering over an
+appropriate entity. Click on each of the remaining member entities one by one to add them to the new
+entity. Once you've added enough members for the entity class, a dialog will pop up. In the dialog,
+all of the possible permutations of the selected members are presented. Check the boxes next to the
+entities you want to add, and press **OK**.
+
+From **Pivot View**
+~~~~~~~~~~~~~~~~~~~
-The *Add relationship classes* dialog will pop up:
+To add an entity to a specific 0-D entity class, bring the class to **Pivot View** using either **Value** or **Index**
+(see :ref:`using_pivot_table_and_frozen_table`). There under the class name just type a new name and the new entity
+will be added under the class. Note that is only possible to add 0-D entities this way even if you have selected
+an N-D class from the **Entity Tree**.
-.. image:: img/add_relationship_classes_dialog.png
- :align: center
+To enter a new entity to an N-D class, select the **Element** -view from the hamburger menu. This view contains
+all of the possible combinations of elements in the selected class. The entities can be added by checking the
+boxes and removed by unchecking them.
-Select the number of dimensions using the spinbox at the top;
-then, enter the names of the object classes for each dimension under each *object class name* column,
-and the names of the relationship classes under *relationship class name*.
-To display a list of available object classes,
-start typing or double click on any cell under the *object class name* columns.
-Optionally, you can enter a description for each relationship class under the *description* column.
-Finally, select the databases where you want to add the relationship classes under *databases*.
-When you're ready, press **Ok**.
+Duplicating entities
+~~~~~~~~~~~~~~~~~~~~
+To duplicate an existing entity with all its parameter values and other associated data, right-click over the
+corresponding entity item in **Entity Tree** to display the context menu, and select **Duplicate entity**. The
+new entity will have the same name with an added (1) to indicate that it is a copy of the original entity. It
+can be renamed to be something else afterwards.
-Adding relationships
-====================
-From *Object tree* or *Relationship tree*
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Adding entity groups
+====================
-Right-click on a relationship class item either in *Object tree* or *Relationship tree*,
-and select **Add relationships** from the context menu.
+Right-click on an entity class item in **Entity Tree**,
+and select **Add entity group** from the context menu.
-The *Add relationships* dialog will pop up:
+The **Add entity group** dialog will pop up:
-.. image:: img/add_relationships_dialog.png
+.. image:: img/add_entity_group_dialog.png
:align: center
-Select the relationship class from the combo box at the top;
-then, enter the names of the objects for each member object class under the corresponding column,
-and the name of the relationship under *relationship name*.
-To display a list of available objects for a member class,
-start typing or double click on any cell under that class's column.
-Finally, select the databases where you want to add the relationships under *databases*.
-When you're ready, press **Ok**.
-
-From *Pivot table*
-~~~~~~~~~~~~~~~~~~
-To add a relationship for a specific class,
-bring the class to *Pivot table* using the **Relationship** input type
-(see :ref:`using_pivot_table_and_frozen_table`).
-The *Pivot table* headers will be populated
-with all possible combinations of objects across the member classes.
-Locate the objects you want as members in the new relationship,
-and check the corresponding box in the table body.
+Enter the name of the group, and select the database where you want the group to be created.
+Select the members under *Non members*, and press (|add|>>) to add the members and (|remove| <<) to remove them.
+Multiple selection is supported with **Ctrl** and **Shift**. Finally press **OK** to create the group.
-From *Entity graph*
-~~~~~~~~~~~~~~~~~~~
-Make sure all the objects you want as members in the new relationship are in the graph.
-To start the relationship, either double click on one of the object items,
-or right click on it to display the context menu, and choose **Add relationships**.
-A menu will pop up showing the available relationship classes.
-Select the class you want; the mouse cursor will adopt a cross-hairs shape.
-Click on each of the remaining member objects, one by one and in the right order, to add them to the relationship.
-Once you've added enough objects for the relationship class, a dialog will pop up.
-Check the boxes next to the relationships you want to add, and press **Ok**.
-
-.. tip:: All the *Add...* dialogs support pasting tabular (spreadsheet) data from the clipboard.
- Just select any cell in the table and press **Ctrl+V**.
- If needed, the table will grow to accommodate the exceeding data.
- To paste data on multiple cells, select all the cells you want to paste on and press **Ctrl+V**.
+When you're happy with your selections, press **OK** to add the group to the database.
Adding parameter definitions
============================
-From *Stacked tables*
-~~~~~~~~~~~~~~~~~~~~~
+From **Table View**
+~~~~~~~~~~~~~~~~~~~~
+
+To add new parameter definitions for an entity class, just fill the last empty row of *Parameter definition*.
+Only two of the fields are required when creating a new parameter definition: *entity_class_name* and
+*parameter_name*. Enter the name of the class under *entity_class_name*. To display a list of available
+entity classes, start typing in the empty cell or double click it. For the name of the parameter choose
+something that isn't already defined for the specified entity class. Optionally, you can also
+specify a parameter value list, a default value and a description.
-To add new parameter definitions for an object class,
-just fill the last empty row of *Object parameter definition*.
-Enter the name of the class under *object_class_name*, and the name of the parameter under *parameter_name*.
-To display a list of available object classes,
-start typing or double click under the *object_class_name* column.
-Optionally, you can also specify a default value, a parameter value list, or any number of parameter tags
-under the appropriate columns.
-The parameter is added when the background of the cells under *object_class_name*
-and *parameter_name* become gray.
+In the column *value_list_name* a name for a parameter value list can be selected. Leaving this field empty
+means that later on when creating parameter values with this definition, the values are arbitrary. Meaning that
+the value could for example be a string or an integer. When the parameter value list is defined in the parameter
+definition, only the values in the list will be allowed to be chosen. For the creation of parameter value lists,
+see :ref:`parameter_value_list`.
-To add new parameter definitions for a relationship class,
-just fill the last empty row of *Relationship parameter definition*,
-following the same guidelines as above.
+In the *default_value* field, the default value can be set. The default value can be used in cases where the value
+is not specified. The usage of *default_value* is really tool dependent, meaning that the Spine Database Editor
+doesn't use the information of the default value anywhere, but it is instead left to the tool creators on how to
+utilize the default value. A short description for the parameter can be written in the *description* column.
+The parameter is added when the background of the cells under *entity_class_name* and *database* become gray.
-From *Pivot table*
-~~~~~~~~~~~~~~~~~~
+
+From **Pivot View**
+~~~~~~~~~~~~~~~~~~~
To add a new parameter definition for a class,
-bring the corresponding class to *Pivot table* using the **Parameter value** input type
+bring the corresponding class to **Pivot View** using the **Value** input type
(see :ref:`using_pivot_table_and_frozen_table`).
-The **parameter** header of *Pivot table* will be populated
+The **parameter** header of **Pivot View** will be populated
with existing parameter definitions for the class.
Enter a name for the new parameter in the last cell of that header.
@@ -184,74 +182,60 @@ Enter a name for the new parameter in the last cell of that header.
Adding parameter values
=======================
-From *Stacked tables*
+From *Table View*
~~~~~~~~~~~~~~~~~~~~~
-To add new parameter values for an object,
-just fill the last empty row of *Object parameter value*.
-Enter the name of the class under *object_class_name*, the name of the object under *object_name*,
+To add new parameter values for an entity, just fill the last empty row of the *Parameter value* -table.
+Enter the name of the class under *entity_class_name*, the name of the entity under *entity_byname*,
the name of the parameter under *parameter_name*, and the name of the alternative under *alternative_name*.
-Optionally, you can also specify the parameter value right away under the *value* column.
-To display a list of available object classes, objects, parameters, or alternatives,
-just start typing or double click under the appropriate column.
-The parameter value is added when the background of the cells under *object_class_name*,
-*object_name*, and *parameter_name* become gray.
-
-To add new parameter values for a relationship class,
-just fill the last empty row of *Relationship parameter value*,
-following the same guidelines as above.
+Optionally, you can also specify the parameter value right away under the *value* column. The database where
+the value will be added to is displayed in the last column of the table. To display a list of available
+entity classes, entities, parameters, or alternatives, just start typing or double click under the appropriate
+column. The parameter value is added when the background of the cells under *entity_class_name* and *database*
+become gray.
-.. note:: To add parameter values for an object, the object has to exist beforehand.
- However, when adding parameter values for a relationship, you can specify any valid combination
- of objects under *object_name_list*, and a relationship will be created among those objects
- if one doesn't yet exist.
+.. note:: To add parameter values for a 0-D entity, the entity has to exist beforehand.
+ However, when adding parameter values for an N-D entity, you can specify any valid combination
+ of elements by double clicking the cell under *entity_byname*, which opens up the *Select elements* -dialog.
+ The specified N-D entity will be created if it doesn't yet exist.
-From *Pivot table*
-~~~~~~~~~~~~~~~~~~
+From **Pivot View**
+~~~~~~~~~~~~~~~~~~~
-To add parameter value for any object or relationship,
-bring the corresponding class to *Pivot table* using the **Parameter value** input type
+To add parameter value for any entity,
+bring the corresponding class to **Pivot View** using the **Value** input type
(see :ref:`using_pivot_table_and_frozen_table`).
Then, enter the parameter value in the corresponding cell in the table body.
-.. tip:: All *Stacked tables* and *Pivot table* support pasting tabular (e.g., spreadsheet) data from the clipboard.
- Just select any cell in the table and press **Ctrl+V**.
- If needed, *Stacked tables* will grow to accommodate the exceeding data.
+.. tip:: All **Tables Views** and **Pivot Views** support pasting tabular (e.g., spreadsheet) data from the clipboard.
+ Just select any cell in the table and press **Ctrl+V**.
+ If needed, **Table Views** will grow to accommodate the exceeding data.
To paste data on multiple cells, select all the cells you want to paste on and press **Ctrl+V**.
-Adding tools, features, and methods
-===================================
-
-To add a new feature, go to *Tool/Feature tree* and select the last item under **feature** in the appropriate database,
-start typing or press **F2** to display available parameter definitions, and select the one you want to become a feature.
-
-.. note:: Only parameter definitions that have associated a parameter value list can become features.
-
-To add a new tool, just select the last item under **tool** in the appropriate database,
-and enter the name of the tool.
-
-To add a feature for a particular tool, drag the feature item and drop it over the **tool_feature** list
-under the corresponding tool.
-
-To add a new method for a tool-feature, select the last item under *tool_feature_method* (in the appropriate database),
-start typing or press **F2** to display available methods, and select the one you want to add.
+Adding entity alternatives
+==========================
+To add an entity alternative, open the **Entity Alternative** -**Table View**. There under *entity_class_name* select
+the class. Under *entity_byname* select the specific entity from that class and from *alternative_name* select the
+alternative. Then set the value of the *active* -column to either true or false by double clicking it. The background
+of the cells under *entity_class_name* and *database* should become gray, indicating that the entity alternative has
+been added.
Adding alternatives
===================
-From *Alternative tree*
-~~~~~~~~~~~~~~~~~~~~~~~
+From **Alternative**
+~~~~~~~~~~~~~~~~~~~~
-To add a new alternative, just select the last item appropriate database,
-and enter the name of the alternative.
+To add a new alternative, just select the last item under the appropriate database, and enter the name of the
+new alternative.
You can also copy and paste alternatives between different databases.
-From *Pivot table*
-~~~~~~~~~~~~~~~~~~
+From **Pivot View**
+~~~~~~~~~~~~~~~~~~~
Select the **Scenario** input type (see :ref:`using_pivot_table_and_frozen_table`).
To add a new alternative, enter a name in the last cell of the **alternative** header.
@@ -260,60 +244,67 @@ To add a new alternative, enter a name in the last cell of the **alternative** h
Adding scenarios
================
-From *Scenario tree*
-~~~~~~~~~~~~~~~~~~~~
+From **Scenario Tree**
+~~~~~~~~~~~~~~~~~~~~~~
To add a new scenario, just select the last item under the appropriate database,
and enter the name of the scenario.
-To add an alternative for a particular scenario, drag the alternative item from *Alternative tree*
+To add an alternative for a particular scenario, drag the alternative item from **Alternative**
and drop it under the corresponding scenario.
The position where you drop it determines the alternative's *rank* within the scenario.
-Alternatives can also be copied from *Alternative tree*
-and pasted at the appropriate position in *Scenario tree*.
+Alternatives can also be copied from **Alternative**
+and pasted at the appropriate position in **Scenario Tree**.
+
+If it is desirable to base a scenario on an existing one, scenarios can be duplicated
+using the **Duplicate** item in the right-click context menu. It is also possible to
+copy and paste scenarios between databases.
.. note:: Alternatives with higher rank have priority when determining the parameter value for a certain scenario.
If the parameter value is specified for two alternatives, and both of them happen to coexist in a same scenario,
the value from the alternative with the higher rank takes precedence.
-If it is desirable to base a scenario on an existing one, scenarios can be duplicated
-using the **Duplicate** item in the right-click context menu, or by pressing **Ctrl+D**.
-It is also possible to copy and paste scenarios between databases.
+.. note:: As noted in the tooltip, scenario names longer than 20 characters may become shortened in generated files.
+ This can happen for example when exporting the scenarios using the Exporter -project item. This can lead to confusion
+ later on if the first 20 characters of the scenario names are identical. Therefore it is recommended to have a unique
+ identifier for each scenario in the first 20 characters of its name.
-From *Pivot table*
-~~~~~~~~~~~~~~~~~~
+From **Pivot View**
+~~~~~~~~~~~~~~~~~~~
Select the **Scenario** input type (see :ref:`using_pivot_table_and_frozen_table`).
To add a new scenario, enter a name in the last cell of the **scenario** header.
-From *Generate scenarios*
-~~~~~~~~~~~~~~~~~~~~~~~~~
+From **Generate scenarios**
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
Scenarios can be added also by automatically generating them from existing alternatives.
-Select the alternatives in *Alternative tree* (using **Ctrl** and **Shift** while clicking the items),
+Select the alternatives in **Alternative** (using **Ctrl** and **Shift** while clicking the items),
then right click to open a context menu.
Select **Generate scenarios...**
-.. image:: img/generate_scenarios.png
+.. image:: img/generate_scenarios_dialog.png
+ :width: 300
:align: center
Give the scenario names a prefix.
An index will be appended to the prefix automatically: **prefix01**, **prefix02**,...
-Select appropriate operation from the combo box.
+Select appropriate operation from the **Operation** combo box.
Checking the **Use base alternative** check box will add the selected alternative to
all generated scenarios as the lowest rank alternative.
The **Alternative by rank** list allows reordering the ranks of the alternatives.
+.. _parameter_value_list:
Adding parameter value lists
============================
-To add a new parameter value list, go to *Parameter value list* and select the last item under the appropriate database,
-and enter the name of the list.
+To add a new parameter value list, go to **Parameter Value List** and select the last item under the appropriate
+database, and enter the name of the list.
-To add new values for the list, select the last empty item under the corresponding
-list item, and enter the value. To enter a complex value, right-click on the empty item and select
-**Open editor** from the context menu.
+To add new values for the list, expand the list with the right-arrow and select the last empty item under the
+corresponding list item, and enter the value. To enter a complex value, right-click on the empty item and select
+**Edit...** from the context menu to open the value editor.
.. note:: To be actually added to the database, a parameter value list must have at least one value.
@@ -321,9 +312,9 @@ list item, and enter the value. To enter a complex value, right-click on the emp
Adding metadata and item metadata
=================================
-To add new metadata go to *Metadata* and add a new name and value to the last row.
+To add new metadata go to **Metadata** and add a new name and value to the last row.
To add a new link metadata for an item,
select an entity from one of the entity trees
or a parameter value from one of the parameter value tables.
-Then go to *Item metadata* and select the appropriate metadata name and value on the last row.
+Then go to **Item metadata** and select the appropriate metadata name and value on the last row.
diff --git a/docs/source/spine_db_editor/committing_and_history.rst b/docs/source/spine_db_editor/committing_and_history.rst
new file mode 100644
index 000000000..01fbca937
--- /dev/null
+++ b/docs/source/spine_db_editor/committing_and_history.rst
@@ -0,0 +1,67 @@
+
+.. _committing_and_history:
+
+Committing and History
+======================
+
+.. contents::
+ :local:
+
+Committing
+----------
+
+.. note:: Changes are not immediately saved to the database(s). They need to be committed separately.
+
+An asterisk (*) in a tab of Spine Database Editor indicates that the session has uncommitted changes:
+
+.. image:: img/dirty_db.png
+ :align: center
+
+If the database is opened from a Data Store, the corresponding project item will also have a notification in its
+upper right corner indicating that the database has uncommitted changes.
+
+To commit your changes, select **Session -> Commit** from the hamburger menu or press **Ctrl+Enter** while the
+Spine Database Editor -window is active to open the commit dialog:
+
+.. image:: img/commit_dialog.png
+ :align: center
+
+There is a default text "Updated" readily filled in. It is however good practise to write a short message
+that clearly describes what changes have been made to the database. Once the commit message is to you liking,
+press **Commit**. Any changes made in the current session will be saved into the database.
+
+If you try to close a tab with uncommitted changes the following dialog will open:
+
+.. image:: img/uncommitted_changes_dialog.png
+ :align: center
+
+There are a few different options: **Commit and close** will do exactly what it says, **Discard changes and close**
+will automatically rollback the changes to the last commit and then close the editor tab. **Cancel** just closes
+the dialog and allows you to work on the database again. If you check the box **Do not ask me again** and select
+any of the other options beside **Cancel**, the selection you made will be automatically presumed whenever you
+close a tab with uncommitted changes. This means that you will not see this dialog again but the changes
+will be automatically committed or not depending on the selection you made previously.
+
+Rollback
+--------
+
+To undo *all* changes since the last commit, select **Session -> Rollback** from the hamburger menu or press
+**Ctrl+Backspace** while the Spine Database Editor -window is active. A dialog confirming the rollback action
+will open. From there, select **Rollback** to proceed.
+
+.. tip:: To undo/redo individual changes, use the **Edit -> Undo** and **Edit -> Redo** actions from the hamburger menu.
+
+.. note:: After rolling back to the last commit, the changes made in the session will no longer be available
+ through undo/redo.
+
+History
+-------
+
+To examine the commit history of the database, select **Session -> History...** from the hamburger menu.
+The **commit viewer** will pop up:
+
+.. image:: img/commit_viewer.png
+ :align: center
+
+All the commits made in the database will show here, each includes the timestamp. author and commit message.
+By selecting individual commits, the affected items can be inspected in the box on the right.
diff --git a/docs/source/spine_db_editor/committing_and_rolling_back.rst b/docs/source/spine_db_editor/committing_and_rolling_back.rst
deleted file mode 100644
index 301e9dcaa..000000000
--- a/docs/source/spine_db_editor/committing_and_rolling_back.rst
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-.. _committing_and_rolling_back:
-
-Committing and rolling back
----------------------------
-
-.. contents::
- :local:
-
-.. note:: Changes are not immediately saved to the database(s). They need to be committed separately.
-
-To commit your changes, select **Session -> Commit** from the hamburger menu, enter a commit message and press **Commit**.
-Any changes made in the current session will be saved into the database.
-
-To undo *all* changes since the last commit, select **Session -> Rollback** from the hamburger menu.
-
-.. tip:: To undo/redo individual changes, use the **Edit -> Undo** and **Edit -> Redo** actions from the hamburger menu.
diff --git a/docs/source/spine_db_editor/getting_started.rst b/docs/source/spine_db_editor/getting_started.rst
index 49c63b195..238c821a6 100644
--- a/docs/source/spine_db_editor/getting_started.rst
+++ b/docs/source/spine_db_editor/getting_started.rst
@@ -1,7 +1,15 @@
+.. |reload| image:: ../../../spinetoolbox/ui/resources/menu_icons/sync.svg
+ :width: 16
+.. |database| image:: ../../../spinetoolbox/ui/resources/database.svg
+ :width: 16
+
***************
Getting started
***************
+This section gives a short outline on how to get started using the editor and how to navigate the ui.
+Information about the settings for the editor can be found in :ref:`DB editor settings`.
+
.. contents::
:local:
@@ -11,49 +19,188 @@ Launching the editor
From Spine Toolbox
==================
-To open a single database in Spine database editor:
+There are two different ways to open a single database in Spine database editor from Spine Toolbox:
+
+Using a *Data Store* project item:
1. Create a *Data Store* project item.
2. Select the *Data Store*.
3. Enter the url of the database in *Data Store Properties*.
4. Press the **Open editor...** button in *Data Store Properties* or double-click the *Data Store* project item.
-To open multiple SQLite databases in Spine database editor:
+Without a *Data Store*:
-1. Open a database in Database editor as explained above.
-2. Select **Add...** from the ☰ menu.
-3. Open the SQLite file.
+1. From the main window select **File -> New DB Editor**.
+2. Open the menu by clicking on the hamburger menu icon (☰) or by pressing **ALT+F** or **ALT+E**.
+3. Select **Open...** to open an existing database, **New..** to create a new one or paste a database URL into
+ the URL bar.
From the command line
=====================
To open a single database in Spine database editor, use the ``spine-db-editor``
-application which comes with Spine Toolbox::
+application which comes with Spine Toolbox. After the virtual environment is activated
+the editor can be opened with the following command::
+
+ spine-db-editor "...url of the database..."
+
+Note that for e.g. an SQLite database, the url should start with ``sqlite:///`` followed by the path.
- spine-db-editor "...url of the database..."
+Adding multiple databases to one editor
+---------------------------------------
-Note that for e.g. an SQLite database, the url should start with ‘sqlite:’.
+It is possible to open multiple databases in the same editor. This allows one to view and modify
+the data of the open databases in one editor.
+To open multiple SQLite databases in the same Spine database editor by file browser:
+
+1. Open a database Database editor using any of the ways explained before.
+2. Select **Add...** from the editor's hamburger menu (☰).
+3. Browse to the directory of the SQLite file and open it.
+
+By using the database URL:
+
+1. Open a database Database editor using any of the ways explained before.
+2. In the URL bar, after the already open database's URL add a semicolon ``;``
+ and after that the URL of the other database to be opened in the same editor.
Knowing the UI
--------------
-The form has the following main UI components:
-
-- *Entity trees* (*Object tree* and *Relationship tree*):
- they present the structure of classes and entities in all databases in the shape of a tree.
-- *Stacked tables* (*Object parameter value*, *Object parameter definition*,
- *Relationship parameter value*, and *Relationship parameter definition*):
- they present object and relationship parameter data in the form of stacked tables.
+When you open an empty database for the first time in a Spine Database Editor, it should look something
+like this:
+
+.. image:: img/plain_db_editor.png
+ :align: center
+
+The dock widgets can be scaled by dragging them from the sides and moved around by dragging them from their
+darker colored headers. Like with other widgets, Toolbox remembers the customizations and the editor will
+open in the same configuration when it is opened the next time. The dock configurations are URL specific.
+the configurations for the URL can be restored back to default from the hamburger menu **View->Docks...->Reset docks**.
+
+Tab bar
+=======
+
+The uppermost UI element is the tab bar. One editor window can have multiple tabs. New tabs can be added by
+pressing the plus-sign (**+**) in the tab bar. In the newly created tab, databases can be opened once again
+with the instructions given above. Tabs can be deleted from the editor by pressing the cross (**X**) inside
+a tab. The tabs can be dragged from the tab bar to create new editor windows. Tabs from different windows
+can also be dragged into others, fusing them into the same editor window.
+
+Navigation bar
+==============
+
+Right below the tab bar there is the navigation bar. With the backwards and forwards arrows it is possible
+to go back to the database that was previously loaded in the specific tab. This is kind of analogous of web
+browsers and going back to the previous page. Next to the arrows there is the **reload** (|reload|) button.
+It can be used to reload the data of the database. Next up is the Data Store icon (|database|) which lists
+the Data Store items in the project and can be used to open any of them in the current tab. The URL bar
+contains the URL of the databases tha are currently open in the tab. As mentioned before, databases can
+be opened by inserting valid database URLs into this field and pressing enter. The URL bar also contains
+the filter (more about this later). After the URL bar there is the Spine-Toolbox logo which when clicked
+brings up the Spine-Toolbox main window. Finally there is the hamburger menu (☰) which holds many of the
+functionalities of the Spine Database Editor.
+
+Hamburger menu
+==============
+
+The hamburger menu (☰) can be located in the upper left corner of the Spine Database Editor. It is the place
+where options and other such things can be found.
+
+File
+~~~~
+
+The uppermost section in the menu is dedicated to actions related
+to databases. There you can create a new Spine db from **New...**, open an existing one from **Open...** or add
+another database to the current tab form **Add...** as explained before. There are also options **Import...**,
+**Export...** and **Export session...**. The importing works kind of like adding another database to the existing
+tab but instead of just opening the other database it brings all of the data from the other database and merges it
+into the current database. With export it is possible to export the current database into it's own ``.sqlite`` file.
+The export session works just like export but instead of exporting the whole database, it exports just the new
+modifications that have been made since the last commit.
+
+Edit
+~~~~
+
+In the **Edit** section there lies the **Undo** and **Redo** -buttons. These can be used to undo and redo the
+actions that have been made in the editor (**CTR+Z** and **CTR+Y** also work). The **Copy name(s) as text** allows
+the user to copy items into the clipboard that can then be pasted elsewhere. The **Paste** option does
+exactly what it says, it pastes the data on the clipboard into the selected field(s). The **Purge...** button is
+quite useful when there is a need to get rid of a lot of data quickly. Clicking it will open a new window where
+options for the purging are given. Find out more about purging in the section :ref:`Removing data`.
+The **Vacuum** option tries to free up some memory from the claws of the database.
+
+View
+~~~~
+
+The different view modes are listed in the **View** -section. Also the **Docks...** button for managing the
+visibility of the UI elements is located here. When switching to the **Value**, **Index** and **Element** views
+something need to selected from the entity tree in order for the view to show anything meaningful. The Graph view
+will show an graphical representation of the entities whereas the table view shows the plain data in table format.
+By pressing the **Docks...** one can customize what UI elements are displayed. This way it is possible to for example
+have the graph and scenario pivot table views open at the same time.
+
+Session
+~~~~~~~
+
+The **Commit..** button is for committing the changes in the database. Pressing the button will open up a commit
+dialog box, where a commit message can be written. The default commit message is just "Updated" but it is good
+practise to write descriptive and concise messages. The **Rollback** button reverts the database to the state
+it was in when it was committed the last time. This means that all modifications to the data that haven't been
+committed will be lost. It is also good to note that this action clears the undo/redo stack which means that the
+operation is irreversible. The **History** button allows one to view the commit history of the database.
+
+Other
+~~~~~
+
+In the bottom part of the hamburger menu there is a button to open the User Guide in a web browser, **Settings**
+button to open the Spine Database Editor settings and a **Close** button for closing the editor. More information
+about the settings can be found in :ref:`DB editor settings`.
+
+Filter
+======
+
+The filter can be used to select which items are allowed to be shown in the editor. The filter is based on scenarios.
+By pressing the filter image in the right end of the URL bar, the filter selector widget opens up. There the desired
+scenario can be selected. When a selection is made and the **Update filters** button is pressed, the changes will be
+applied to the editor. Now all entities, parameters etc. will be filtered out if they don't belong to the scenario
+specified in the filter.
+
+.. tip:: Note that after applying the filter, the URL gets updated with some additional information about the filters.
+ It is therefore possible to make changes to the filtering just by modifying the URL from the URL bar.
+
+Undo and redo
+=============
+
+Whenever changes are made to the data in the Spine Database Editor, the changes get stored into memory. This
+allows undoing and redoing the operations made in the editor. Buttons for these operations can be found in the
+hamburger menu and the usual shortcuts **Ctrl+Z** and **Ctrl+Y** work also. However if the changes are committed,
+the memory for the changes gets cleared meaning that the changes before the commit can't be undone anymore.
+
+Views and trees
+===============
+
+Spine Database Editor has the following main UI components:
+
+- *Entity tree*:
+ they present the structure of entities in all databases in the shape of a tree.
+- *Table views* (*Parameter value*, *Parameter definition*, *Entity alternative*):
+ they present entity data in the form of stacked tables.
- *Pivot table* and *Frozen table*: they present data in the form of a pivot table,
optionally with frozen dimensions.
-- *Entity graph*: it presents the structure of classes and entities in the shape of a graph.
-- *Tool/Feature tree*: it presents tools, features, and methods defined in the databases.
+- *Graph view*: it presents the structure of classes and entities in the shape of a graph.
- *Parameter value list*: it presents parameter value lists available in the databases.
-- *Alternative tree*: it presents alternatives defined in the databases.
-- *Scenario tree*: it presents scenarios defined in the databases.
+- *Alternative*: it presents alternatives defined in the databases in the shape of a tree.
+- *Scenario tree*: it presents scenarios defined in the databases in the shape of a tree.
- *Metadata*: presents metadata defined in the databases.
- *Item metadata*: shows metadata associated with the currently selected entities or parameter values.
-.. tip:: You can customize the UI from the **View** and **Pivot** sections in the hamburger ☰ menu.
+.. tip:: You can customize the UI from the **View** section in the hamburger ☰ menu. There the **Docks...**
+ menu can be used to enable and disable the different UI components listed above.
+
+Items from the trees can be selected by clicking them with the left mouse button and the views will react to
+the changes. By default, multiple items can be selected at the same time across the trees by holding down **Ctrl**
+while making the selections. This behavior can be flipped from the editor settings (**Ctrl+,**) by toggling the
+*Sticky selection* -setting.
+In the next section you will learn more about the different UI components and views available in the editor
diff --git a/docs/source/spine_db_editor/img/EAV_CR.png b/docs/source/spine_db_editor/img/EAV_CR.png
deleted file mode 100644
index 75e07e884..000000000
Binary files a/docs/source/spine_db_editor/img/EAV_CR.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/add_entities_dialog.png b/docs/source/spine_db_editor/img/add_entities_dialog.png
new file mode 100644
index 000000000..83bf59f91
Binary files /dev/null and b/docs/source/spine_db_editor/img/add_entities_dialog.png differ
diff --git a/docs/source/spine_db_editor/img/add_entities_dialog_2D.png b/docs/source/spine_db_editor/img/add_entities_dialog_2D.png
new file mode 100644
index 000000000..0b4b47254
Binary files /dev/null and b/docs/source/spine_db_editor/img/add_entities_dialog_2D.png differ
diff --git a/docs/source/spine_db_editor/img/add_entity_classes_dialog.png b/docs/source/spine_db_editor/img/add_entity_classes_dialog.png
new file mode 100644
index 000000000..ccfcbdd14
Binary files /dev/null and b/docs/source/spine_db_editor/img/add_entity_classes_dialog.png differ
diff --git a/docs/source/spine_db_editor/img/add_entity_classes_dialog_2D.png b/docs/source/spine_db_editor/img/add_entity_classes_dialog_2D.png
new file mode 100644
index 000000000..63125c01c
Binary files /dev/null and b/docs/source/spine_db_editor/img/add_entity_classes_dialog_2D.png differ
diff --git a/docs/source/spine_db_editor/img/add_entity_group_dialog.png b/docs/source/spine_db_editor/img/add_entity_group_dialog.png
new file mode 100644
index 000000000..150b89c60
Binary files /dev/null and b/docs/source/spine_db_editor/img/add_entity_group_dialog.png differ
diff --git a/docs/source/spine_db_editor/img/add_object_classes_dialog.png b/docs/source/spine_db_editor/img/add_object_classes_dialog.png
deleted file mode 100644
index 3696824dc..000000000
Binary files a/docs/source/spine_db_editor/img/add_object_classes_dialog.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/add_object_group_dialog.png b/docs/source/spine_db_editor/img/add_object_group_dialog.png
deleted file mode 100644
index 76a58354f..000000000
Binary files a/docs/source/spine_db_editor/img/add_object_group_dialog.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/add_objects_dialog.png b/docs/source/spine_db_editor/img/add_objects_dialog.png
deleted file mode 100644
index 5206888ec..000000000
Binary files a/docs/source/spine_db_editor/img/add_objects_dialog.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/add_relationship_classes_dialog.png b/docs/source/spine_db_editor/img/add_relationship_classes_dialog.png
deleted file mode 100644
index d1e74ad1d..000000000
Binary files a/docs/source/spine_db_editor/img/add_relationship_classes_dialog.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/add_relationships_dialog.png b/docs/source/spine_db_editor/img/add_relationships_dialog.png
deleted file mode 100644
index dbdf642d2..000000000
Binary files a/docs/source/spine_db_editor/img/add_relationships_dialog.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/alternative_tree.png b/docs/source/spine_db_editor/img/alternative_tree.png
index 228dfba5a..d4e2c5b69 100644
Binary files a/docs/source/spine_db_editor/img/alternative_tree.png and b/docs/source/spine_db_editor/img/alternative_tree.png differ
diff --git a/docs/source/spine_db_editor/img/commit_dialog.png b/docs/source/spine_db_editor/img/commit_dialog.png
new file mode 100644
index 000000000..967da5553
Binary files /dev/null and b/docs/source/spine_db_editor/img/commit_dialog.png differ
diff --git a/docs/source/spine_db_editor/img/commit_viewer.png b/docs/source/spine_db_editor/img/commit_viewer.png
new file mode 100644
index 000000000..cd940c20e
Binary files /dev/null and b/docs/source/spine_db_editor/img/commit_viewer.png differ
diff --git a/docs/source/spine_db_editor/img/cs-c2.png b/docs/source/spine_db_editor/img/cs-c2.png
deleted file mode 100644
index 202a88388..000000000
Binary files a/docs/source/spine_db_editor/img/cs-c2.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/dirty_db.png b/docs/source/spine_db_editor/img/dirty_db.png
new file mode 100644
index 000000000..5d8cce613
Binary files /dev/null and b/docs/source/spine_db_editor/img/dirty_db.png differ
diff --git a/docs/source/spine_db_editor/img/edit_entity_classes_dialog.png b/docs/source/spine_db_editor/img/edit_entity_classes_dialog.png
new file mode 100644
index 000000000..80fcbd9c9
Binary files /dev/null and b/docs/source/spine_db_editor/img/edit_entity_classes_dialog.png differ
diff --git a/docs/source/spine_db_editor/img/edit_object_classes_dialog.png b/docs/source/spine_db_editor/img/edit_object_classes_dialog.png
deleted file mode 100644
index 956576382..000000000
Binary files a/docs/source/spine_db_editor/img/edit_object_classes_dialog.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/entity_alternative_table.png b/docs/source/spine_db_editor/img/entity_alternative_table.png
new file mode 100644
index 000000000..cdb271b04
Binary files /dev/null and b/docs/source/spine_db_editor/img/entity_alternative_table.png differ
diff --git a/docs/source/spine_db_editor/img/entity_graph.png b/docs/source/spine_db_editor/img/entity_graph.png
index b70fcc256..061dc7dbc 100644
Binary files a/docs/source/spine_db_editor/img/entity_graph.png and b/docs/source/spine_db_editor/img/entity_graph.png differ
diff --git a/docs/source/spine_db_editor/img/entity_icon_editor.png b/docs/source/spine_db_editor/img/entity_icon_editor.png
new file mode 100644
index 000000000..6abd5c95d
Binary files /dev/null and b/docs/source/spine_db_editor/img/entity_icon_editor.png differ
diff --git a/docs/source/spine_db_editor/img/entity_name_filter_menu.png b/docs/source/spine_db_editor/img/entity_name_filter_menu.png
new file mode 100644
index 000000000..a13d7a9c7
Binary files /dev/null and b/docs/source/spine_db_editor/img/entity_name_filter_menu.png differ
diff --git a/docs/source/spine_db_editor/img/entity_parameter_value_table.png b/docs/source/spine_db_editor/img/entity_parameter_value_table.png
new file mode 100644
index 000000000..1c007f122
Binary files /dev/null and b/docs/source/spine_db_editor/img/entity_parameter_value_table.png differ
diff --git a/docs/source/spine_db_editor/img/entity_tree.png b/docs/source/spine_db_editor/img/entity_tree.png
new file mode 100644
index 000000000..4d7da96cf
Binary files /dev/null and b/docs/source/spine_db_editor/img/entity_tree.png differ
diff --git a/docs/source/spine_db_editor/img/entity_tree_context_menu.png b/docs/source/spine_db_editor/img/entity_tree_context_menu.png
new file mode 100644
index 000000000..3f649232d
Binary files /dev/null and b/docs/source/spine_db_editor/img/entity_tree_context_menu.png differ
diff --git a/docs/source/spine_db_editor/img/excel_entity_sheet.png b/docs/source/spine_db_editor/img/excel_entity_sheet.png
new file mode 100644
index 000000000..4776075c6
Binary files /dev/null and b/docs/source/spine_db_editor/img/excel_entity_sheet.png differ
diff --git a/docs/source/spine_db_editor/img/excel_entity_sheet_timeseries.png b/docs/source/spine_db_editor/img/excel_entity_sheet_timeseries.png
new file mode 100644
index 000000000..8138cc6d7
Binary files /dev/null and b/docs/source/spine_db_editor/img/excel_entity_sheet_timeseries.png differ
diff --git a/docs/source/spine_db_editor/img/excel_object_sheet.png b/docs/source/spine_db_editor/img/excel_object_sheet.png
deleted file mode 100644
index b237bdc25..000000000
Binary files a/docs/source/spine_db_editor/img/excel_object_sheet.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/excel_object_sheet_timeseries.png b/docs/source/spine_db_editor/img/excel_object_sheet_timeseries.png
deleted file mode 100644
index ad07e8da1..000000000
Binary files a/docs/source/spine_db_editor/img/excel_object_sheet_timeseries.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/excel_relationship_sheet.png b/docs/source/spine_db_editor/img/excel_relationship_sheet.png
deleted file mode 100644
index a19e4cf12..000000000
Binary files a/docs/source/spine_db_editor/img/excel_relationship_sheet.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/excel_relationship_sheet_timeseries.png b/docs/source/spine_db_editor/img/excel_relationship_sheet_timeseries.png
deleted file mode 100644
index aaa097339..000000000
Binary files a/docs/source/spine_db_editor/img/excel_relationship_sheet_timeseries.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/export_bar.png b/docs/source/spine_db_editor/img/export_bar.png
new file mode 100644
index 000000000..4de3bb2a9
Binary files /dev/null and b/docs/source/spine_db_editor/img/export_bar.png differ
diff --git a/docs/source/spine_db_editor/img/generate_scenarios.png b/docs/source/spine_db_editor/img/generate_scenarios.png
deleted file mode 100644
index 271ee2fd4..000000000
Binary files a/docs/source/spine_db_editor/img/generate_scenarios.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/generate_scenarios_dialog.png b/docs/source/spine_db_editor/img/generate_scenarios_dialog.png
new file mode 100644
index 000000000..039bc48d5
Binary files /dev/null and b/docs/source/spine_db_editor/img/generate_scenarios_dialog.png differ
diff --git a/docs/source/spine_db_editor/img/manage_entities_dialog.png b/docs/source/spine_db_editor/img/manage_entities_dialog.png
new file mode 100644
index 000000000..1a92b4ec6
Binary files /dev/null and b/docs/source/spine_db_editor/img/manage_entities_dialog.png differ
diff --git a/docs/source/spine_db_editor/img/manage_members_dialog.png b/docs/source/spine_db_editor/img/manage_members_dialog.png
index 2dfd1b30f..472cce603 100644
Binary files a/docs/source/spine_db_editor/img/manage_members_dialog.png and b/docs/source/spine_db_editor/img/manage_members_dialog.png differ
diff --git a/docs/source/spine_db_editor/img/manage_parameter_tags_dialog.png b/docs/source/spine_db_editor/img/manage_parameter_tags_dialog.png
deleted file mode 100644
index 1e8dc4d71..000000000
Binary files a/docs/source/spine_db_editor/img/manage_parameter_tags_dialog.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/manage_relationships_dialog.png b/docs/source/spine_db_editor/img/manage_relationships_dialog.png
deleted file mode 100644
index 981966855..000000000
Binary files a/docs/source/spine_db_editor/img/manage_relationships_dialog.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/mass_export_items_dialog.png b/docs/source/spine_db_editor/img/mass_export_items_dialog.png
index 156ca1ae4..511fa3f37 100644
Binary files a/docs/source/spine_db_editor/img/mass_export_items_dialog.png and b/docs/source/spine_db_editor/img/mass_export_items_dialog.png differ
diff --git a/docs/source/spine_db_editor/img/mass_remove_items_dialog.png b/docs/source/spine_db_editor/img/mass_remove_items_dialog.png
deleted file mode 100644
index 8d0dee18c..000000000
Binary files a/docs/source/spine_db_editor/img/mass_remove_items_dialog.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/object_name_filter_menu.png b/docs/source/spine_db_editor/img/object_name_filter_menu.png
deleted file mode 100644
index 61039b6af..000000000
Binary files a/docs/source/spine_db_editor/img/object_name_filter_menu.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/object_parameter_value_table.png b/docs/source/spine_db_editor/img/object_parameter_value_table.png
deleted file mode 100644
index ffe42e265..000000000
Binary files a/docs/source/spine_db_editor/img/object_parameter_value_table.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/object_tree.png b/docs/source/spine_db_editor/img/object_tree.png
deleted file mode 100644
index 5bd3073ae..000000000
Binary files a/docs/source/spine_db_editor/img/object_tree.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/parameter_tag.png b/docs/source/spine_db_editor/img/parameter_tag.png
deleted file mode 100644
index 5700da0df..000000000
Binary files a/docs/source/spine_db_editor/img/parameter_tag.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/parameter_value_list.png b/docs/source/spine_db_editor/img/parameter_value_list.png
index ccc453b77..23c59091b 100644
Binary files a/docs/source/spine_db_editor/img/parameter_value_list.png and b/docs/source/spine_db_editor/img/parameter_value_list.png differ
diff --git a/docs/source/spine_db_editor/img/pivot_table.png b/docs/source/spine_db_editor/img/pivot_table.png
index 7c61f1008..a42fcb4c4 100644
Binary files a/docs/source/spine_db_editor/img/pivot_table.png and b/docs/source/spine_db_editor/img/pivot_table.png differ
diff --git a/docs/source/spine_db_editor/img/plain_db_editor.png b/docs/source/spine_db_editor/img/plain_db_editor.png
new file mode 100644
index 000000000..19c1c1947
Binary files /dev/null and b/docs/source/spine_db_editor/img/plain_db_editor.png differ
diff --git a/docs/source/spine_db_editor/img/purge_dialog.png b/docs/source/spine_db_editor/img/purge_dialog.png
new file mode 100644
index 000000000..1522484a9
Binary files /dev/null and b/docs/source/spine_db_editor/img/purge_dialog.png differ
diff --git a/docs/source/spine_db_editor/img/remove_entities_dialog.png b/docs/source/spine_db_editor/img/remove_entities_dialog.png
index 7d4d16ac8..adb5c8290 100644
Binary files a/docs/source/spine_db_editor/img/remove_entities_dialog.png and b/docs/source/spine_db_editor/img/remove_entities_dialog.png differ
diff --git a/docs/source/spine_db_editor/img/scenario_tree.png b/docs/source/spine_db_editor/img/scenario_tree.png
index 2b557bbd3..d9451a78d 100644
Binary files a/docs/source/spine_db_editor/img/scenario_tree.png and b/docs/source/spine_db_editor/img/scenario_tree.png differ
diff --git a/docs/source/spine_db_editor/img/tool_feature_tree.png b/docs/source/spine_db_editor/img/tool_feature_tree.png
deleted file mode 100644
index b7730fb9b..000000000
Binary files a/docs/source/spine_db_editor/img/tool_feature_tree.png and /dev/null differ
diff --git a/docs/source/spine_db_editor/img/uncommitted_changes_dialog.png b/docs/source/spine_db_editor/img/uncommitted_changes_dialog.png
new file mode 100644
index 000000000..1eb44b4a6
Binary files /dev/null and b/docs/source/spine_db_editor/img/uncommitted_changes_dialog.png differ
diff --git a/docs/source/spine_db_editor/importing_and_exporting_data.rst b/docs/source/spine_db_editor/importing_and_exporting_data.rst
index 97797b44f..67f0278cf 100644
--- a/docs/source/spine_db_editor/importing_and_exporting_data.rst
+++ b/docs/source/spine_db_editor/importing_and_exporting_data.rst
@@ -10,235 +10,262 @@ This section describes the available tools to import and export data.
Overview
========
-Spine database editor supports importing and exporting data in three different formats: SQLite, JSON, and Excel.
-The SQLite import/export uses the Spine database format.
-The JSON and Excel import/export use a specific format described below.
+Spine Database Editor supports importing and exporting data in three different formats: SQLite, JSON, and Excel.
+The SQLite import/export uses the Spine Database format. The JSON and Excel import/export use a specific format
+described in :ref:`format_specifications`.
+
+Importing
+=========
+
+To import a file, select **File --> Import** from the hamburger menu.
+The *Import file* dialog will pop up.
+Select the file type (SQLite, JSON, or Excel), enter the path of the file to import, and accept the dialog.
+
+.. tip:: You can undo import operations using **Edit -> Undo**.
+
+Exporting
+=========
+
+Mass export
+~~~~~~~~~~~
+
+To export items in mass, select **File --> Export** from the hamburger menu.
+The *Export items* dialog will pop up:
+
+.. image:: img/mass_export_items_dialog.png
+ :align: center
+
+Select the databases you want to export under *Databases*, and the type of items under *Items*,
+then press **Ok**.
+The *Export file* dialog will pop up now.
+Select the file type (SQLite, JSON, or Excel), enter the path of the file to export, and accept the dialog.
+
+
+Selective export
+~~~~~~~~~~~~~~~~
+
+To export a specific subset of items, select the corresponding items in the **Entity Tree**,
+right click on the selection to bring the context menu, and select **Export**.
+
+The *Export file* dialog will pop up.
+Select the file type (SQLite, JSON, or Excel), enter the path of the file to export, and accept the dialog.
+Session export
+~~~~~~~~~~~~~~
+
+To export only uncommitted changes made in the current session, select **File --> Export session** from
+the hamburger menu.
+
+The *Export file* dialog will pop up.
+Select the file type (SQLite, JSON, or Excel), enter the path of the file to export, and accept the dialog.
+
+.. note:: Export operations include all uncommitted changes.
+
+
+Accessing/using exported files
+==============================
+
+Whenever you successfully export a file,
+a button with the file name is created in the *Exports* bar at the bottom of the form.
+Pressing that button will open the JSON or Excel file with the default program that your
+system associates with that filetype. Exports of SQLite file type will be opened in a new tab
+of the Spine Database Editor. To open the folder containing the export, click on the arrow next
+to the file name and select **Open containing folder** from the popup menu.
+
+ .. image:: img/export_bar.png
+ :align: center
+
+.. _format_specifications:
+
+Format specifications
+=====================
+
.. tip:: To create a template file with the JSON or Excel format you can simply export an existing Spine database
into one of those formats.
Excel format
~~~~~~~~~~~~
+.. note:: Excel exports are not comprehensive. Even though every type of item is selectable in
+ the exporting selection, sheets will be generated only for some of the selections.
+ Things like metadata and parameter value lists don't currently have export support with excel.
+ The JSON export on the other hand is comprehensive and will export every detail about the
+ database.
-The Excel format consists of one sheet per object and relationship class.
-Each sheet can have one of four different formats:
+When parameter values are exported, the generated Excel will have every entity class on its own sheet.
+If the entity has indexed values (time-series, map etc.) as well as single values (floats, strings etc.)
+the entity will have more than one sheet, one containing the single values and others that unpack the
+indexed values:
-1. Object class with scalar parameter data:
+scalar parameter data:
- .. image:: img/excel_object_sheet.png
+ .. image:: img/excel_entity_sheet.png
:align: center
-2. Object class with indexed parameter data:
+indexed parameter data:
- .. image:: img/excel_object_sheet_timeseries.png
+ .. image:: img/excel_entity_sheet_timeseries.png
:align: center
-3. Relationship class with scalar parameter data:
- .. image:: img/excel_relationship_sheet.png
- :align: center
+JSON format
+~~~~~~~~~~~
-4. Relationship class with indexed parameter data:
+The JSON export is complete since it contains all of the data from the database.
+The JSON format consists of a single JSON object with the following ``OPTIONAL`` keys:
- .. image:: img/excel_relationship_sheet_timeseries.png
- :align: center
+- **entity_classes**: the value of this key ``MUST`` be a JSON array,
+ representing a list of entity classes.
+ Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have three elements:
+ - The first element ``MUST`` be a JSON string, indicating the entity class name.
+ - The second element ``MUST`` be a JSON array, indicating the member entity classes. Each element in
+ this array ``MUST`` be a JSON string, indicating the entity class name. In case of 0-D entity class,
+ the array is empty.
+ - The third element ``MUST`` be either a JSON string, indicating the entity class description, or null.
+ - The fourth element ``MUST`` be either a JSON integer, indicating the entity class icon code, or null.
+ - The fourth element ``MUST`` be a JSON boolean, indicating the state of active by default.
-JSON format
-~~~~~~~~~~~
+- **superclass_subclasses**: the value of this key ``MUST`` be a JSON array,
+ representing a list of superclasses.
+ Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have two elements:
-The JSON format consists of a single JSON object with the following ``OPTIONAL`` keys:
+ - The first element ``MUST`` be a JSON string, indicating the superclass name.
+ - The second element ``MUST`` be a JSON string, indicating the subclass name.
-- **object_classes**: the value of this key ``MUST`` be a JSON array,
- representing a list of object classes.
+- **entities**: the value of this key ``MUST`` be a JSON array,
+ representing a list of entities.
Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have three elements:
- - The first element ``MUST`` be a JSON string, indicating the object class name.
- - The second element ``MUST`` be either a JSON string, indicating the object class description, or null.
- - The third element ``MUST`` be either a JSON integer, indicating the object class icon code, or null.
+ - The first element ``MUST`` be a JSON string, indicating the entity class name.
+ - The second element ``MUST`` be a JSON array, if the entity is N-dimensional. In this case each element in
+ the array ``MUST`` be a JSON string itself, each being an element of the entity. If the entity class is 0-D,
+ this element ``MUST`` be a JSON string, indicating the entity name.
+ - The third element ``MUST`` be either a JSON string, indicating the entity description, or null.
+
+- **Entity alternatives**: the value of this key ``MUST`` be a JSON array,
+ representing a list of entity alternatives.
+ Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have four elements:
-- **relationship_classes**: the value of this key ``MUST`` be a JSON array,
- representing a list of relationships classes.
+ - The first element ``MUST`` be a JSON string, indicating the entity class name.
+ - The second element ``MUST`` be either a JSON array or a JSON string. In the case of a N-dimensional entity
+ the array ``MUST`` itself contain JSON strings representing the element name list of the entity.
+ If the entity is 0-D, a JSON string of the name of the entity is enough, but also a JSON array of one element
+ is supported.
+
+- **entity_groups**: the value of this key ``MUST`` be a JSON array,
+ representing a list of entity groups.
Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have three elements:
- - The first element ``MUST`` be a JSON string, indicating the relationship class name.
- - The second element ``MUST`` be a JSON array, indicating the member object classes.
- Each element in this array ``MUST`` be a JSON string, indicating the object class name.
- - The third element ``MUST`` be either a JSON string, indicating the relationship class description, or null.
+ - The first element ``MUST`` be a JSON string, indicating the entity class.
+ - The second element ``MUST`` be a JSON string, indicating the entity group name.
+ - The third element ``MUST`` be a JSON string, indicating the member entity's name.
- **parameter_value_lists**: the value of this key ``MUST`` be a JSON array,
representing a list of parameter value lists.
Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have two elements:
- The first element ``MUST`` be a JSON string, indicating the parameter value list name.
- - The second element ``MUST`` be a JSON array, indicating the values in the list.
- Each element in this array ``MUST`` be either a JSON object, string, number, or null,
+ - The second element ``MUST`` be either a JSON object, string, number, or null,
indicating the value.
-- **object_parameters**: the value of this key ``MUST`` be a JSON array,
- representing a list of object parameter definitions.
+- **parameter_definitions**: the value of this key ``MUST`` be a JSON array,
+ representing a list of parameter definitions.
Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have five elements:
- - The first element ``MUST`` be a JSON string, indicating the object class name.
+ - The first element ``MUST`` be a JSON string, indicating the entity class name.
- The second element ``MUST`` be a JSON string, indicating the parameter name.
- The third element ``MUST`` be either a JSON object, string, number, or null,
indicating the parameter default value.
- The fourth element ``MUST`` be a JSON string, indicating the associated parameter value list, or null.
- - The last element ``MUST`` be either a JSON string, indicating the parameter description, or null.
-
-- **relationship_parameters**: the value of this key ``MUST`` be a JSON array,
- representing a list of relationship parameter definitions.
- Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have five elements:
-
- - The first element ``MUST`` be a JSON string, indicating the relationship class name.
- - The second element ``MUST`` be a JSON string, indicating the parameter name.
- - The third element ``MUST`` be either a JSON object, string, number, or null,
- indicating the parameter default value.
- - The fourth element ``MUST`` be a JSON string, indicating the associated parameter value list, or null
- - The last element ``MUST`` be either a JSON string, indicating the parameter description, or null.
-
-- **objects**: the value of this key ``MUST`` be a JSON array,
- representing a list of objects.
- Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have three elements:
-
- - The first element ``MUST`` be a JSON string, indicating the object class name.
- - The second element ``MUST`` be a JSON string, indicating the object name.
- - The third element ``MUST`` be either a JSON string, indicating the object description, or null.
-
-- **relationships**: the value of this key ``MUST`` be a JSON array,
- representing a list of relationships.
- Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have two elements:
-
- - The first element ``MUST`` be a JSON string, indicating the relationship class name.
- - The second element ``MUST`` be a JSON array, indicating the member objects.
- Each element in this array ``MUST`` be a JSON string, indicating the object name.
-
-- **object_parameter_values**: the value of this key ``MUST`` be a JSON array,
- representing a list of object parameter values.
- Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have four elements:
-
- - The first element ``MUST`` be a JSON string, indicating the object class name.
- - The second element ``MUST`` be a JSON string, indicating the object name.
- - The third element ``MUST`` be a JSON string, indicating the parameter name.
- - The fourth element ``MUST`` be either a JSON object, string, number, or null,
- indicating the parameter value.
+ - The fifth element ``MUST`` be either a JSON string, indicating the parameter description, or null.
-- **relationship_parameter_values**: the value of this key ``MUST`` be a JSON array,
- representing a list of relationship parameter values.
+- **parameter_values**: the value of this key ``MUST`` be a JSON array,
+ representing a list of entity parameter values.
Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have four elements:
- - The first element ``MUST`` be a JSON string, indicating the relationship class name.
- - The second element ``MUST`` be a JSON array, indicating the relationship's member objects.
- Each element in this array ``MUST`` be a JSON string, indicating the object name.
+ - The first element ``MUST`` be a JSON string, indicating the entity class name.
+ - The second element ``MUST`` be a JSON array, if the entity is N-dimensional. In this case each element in
+ the array ``MUST`` be a JSON string itself, each being an element of the entity. If the entity class is 0-D,
+ this element ``MUST`` be a JSON string, indicating the entity name.
- The third element ``MUST`` be a JSON string, indicating the parameter name.
- The fourth element ``MUST`` be either a JSON object, string, number, or null,
indicating the parameter value.
-Example::
+ There is one ``OPTIONAL`` element:
- {
- "object_classes": [
- ["connection", "An entity where an energy transfer takes place", 280378317271233],
- ["node", "An entity where an energy balance takes place", 280740554077951],
- ["unit", "An entity where an energy conversion process takes place", 281470681805429],
- ],
- "relationship_classes": [
- ["connection__node__node", ["connection", "node", "node"] , null],
- ["unit__from_node", ["unit", "node"], null],
- ["unit__to_node", ["unit", "node"], null],
- ],
- "parameter_value_lists": [
- ["balance_type_list", ["\"balance_type_node\"", "\"balance_type_group\"", "\"balance_type_none\""]],
- ["truth_value_list", ["\"value_false\"", "\"value_true\""]],
- ],
- "object_parameters": [
- ["connection", "connection_availability_factor", 1.0, null, null],
- ["node", "balance_type", "balance_type_node", "balance_type_list", null],
- ],
- "relationship_parameters": [
- ["connection__node__node", "connection_flow_delay", {"type": "duration", "data": "0h"}, null, null],
- ["unit__from_node", "unit_capacity", null, null, null],
- ["unit__to_node", "unit_capacity", null, null, null],
- ],
- "objects": [
- ["connection", "Bastusel_to_Grytfors_disch", null],
- ["node", "Bastusel_lower", null],
- ["node", "Bastusel_upper", null],
- ["node", "Grytfors_upper", null],
- ["unit", "Bastusel_pwr_plant", null],
- ],
- "relationships": [
- ["connection__node__node", ["Bastusel_to_Grytfors_disch", "Grytfors_upper", "Bastusel_lower"]],
- ["unit__from_node", ["Bastusel_pwr_plant", "Bastusel_upper"]],
- ["unit__to_node", ["Bastusel_pwr_plant", "Bastusel_lower"]],
- ],
- "object_parameter_values": [
- ["node", "Bastusel_upper", "demand", -0.2579768519],
- ["node", "Bastusel_upper", "fix_node_state", {"type": "time_series", "data": {"2018-12-31T23:00:00": 5581.44, "2019-01-07T23:00:00": 5417.28}}],
- ["node", "Bastusel_upper", "has_state", "value_true"],
- ],
- "relationship_parameter_values": [
- ["connection__node__node", ["Bastusel_to_Grytfors_disch", "Grytfors_upper", "Bastusel_lower"], "connection_flow_delay", {"type": "duration", "data": "1h"}],
- ["unit__from_node", ["Bastusel_pwr_plant", "Bastusel_upper"], "unit_capacity", 127.5],
- ]
- }
+ - The fifth element ``MUST`` either be a JSON string indicating the alternative, or null. If this element
+ is not present, an alternative named Base will be created if it doesn't exist and the values will be set
+ in that alternative.
-Importing
-=========
-
-To import a file, select **File --> Import** from the hamburger menu.
-The *Import file* dialog will pop up.
-Select the file type (SQLite, JSON, or Excel), enter the path of the file to import, and accept the dialog.
-
-.. tip:: You can undo import operations using **Edit -> Undo**.
-
-Exporting
-=========
-
-Mass export
-~~~~~~~~~~~
-
-To export items in mass, select **File --> Export** from the hamburger menu.
-The *Export items* dialog will pop up:
-
-.. image:: img/mass_export_items_dialog.png
- :align: center
-
-Select the databases you want to export under *Databases*, and the type of items under *Items*,
-then press **Ok**.
-The *Export file* dialog will pop up now.
-Select the file type (SQLite, JSON, or Excel), enter the path of the file to export, and accept the dialog.
-
-
-Selective export
-~~~~~~~~~~~~~~~~
+- **alternatives**: the value of this key ``MUST`` be a JSON array,
+ representing a list of alternatives.
+ Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have two elements:
-To export a specific subset of items, select the corresponding items in either *Object tree*
-and *Relationship tree*, right click on the selection to bring the context menu,
-and select **Export**.
-
-The *Export file* dialog will pop up.
-Select the file type (SQLite, JSON, or Excel), enter the path of the file to export, and accept the dialog.
+ - The first element ``MUST`` be a JSON string, indicating the alternative name
+ - The second element ``MUST`` be either a JSON string, indicating the alternative description, or null.
+- **scenarios**: the value of this key ``MUST`` be a JSON array,
+ representing a list of alternatives.
+ Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have two elements:
-Session export
-~~~~~~~~~~~~~~
+ - The first element ``MUST`` be a JSON string, indicating the scenario name.
+ - The second element ``MUST`` be a JSON boolean, indicating the scenario alternative active state.
+ - The third element ``MUST`` be either a JSON string, indicating the scenario description, or null.
-To export only uncommitted changes made in the current session, select **File --> Export session** from
-the hamburger menu.
+- **scenario alternatives**: the value of this key ``MUST`` be a JSON array,
+ representing a list of alternatives.
+ Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have three elements:
-The *Export file* dialog will pop up.
-Select the file type (SQLite, JSON, or Excel), enter the path of the file to export, and accept the dialog.
+ - The first element ``MUST`` be a JSON string, indicating the scenario name.
+ - The second element ``MUST`` be a JSON string, indicating the alternative name aowfiuhwaofiajw.
-.. note:: Export operations include all uncommitted changes.
-Accessing/using exported files
-==============================
+Example::
-Whenever you successfully export a file,
-a button with the file name is created in the *Exports* bar at the bottom of the form.
-To open the file in your registered program, press that button.
-To open the containing folder,
-click on the arrow next to the file name and select **Open containing folder** from the popup menu.
\ No newline at end of file
+ {
+ "entity_classes": [
+ ["connection",[],"A transfer of commodities between nodes. E.g. electricity line,gas pipeline...",280378317271233,true],
+ ["node",[],"A universal aggregator of commodify flows over units and connections,with storage capabilities.",280740554077951,true],
+ ["unit",[],"A conversion of one/many comodities between nodes.",281470681805429,true],
+ ["unit__from_node",["unit","node"],"Defines the `nodes` the `unit` can take input from,and holds most `unit_flow` variable specific parameters.",281470681805657,true],
+ ["unit__to_node",["unit","node"],"Defines the `nodes` the `unit` can output to,and holds most `unit_flow` variable specific parameters.",281470681805658,true],
+ ["connection__node__node",["connection","node","node"],"Holds parameters spanning multiple `connection_flow` variables to and from multiple `nodes`.",null,true]
+ ],
+ "entities": [
+ ["connection","Bastusel_to_Grytfors_disch",null],
+ ["node","Bastusel_lower",null],
+ ["node","Bastusel_upper",null],
+ ["node","Grytfors_upper",null],
+ ["unit","Bastusel_pwr_plant",null],
+ ["unit__from_node",["Bastusel_pwr_plant","Bastusel_upper"],null],
+ ["unit__to_node",["Bastusel_pwr_plant","Bastusel_lower"],null],
+ ["connection__node__node",["Bastusel_to_Grytfors_disch","Grytfors_upper","Bastusel_lower"],null]
+ ],
+ "parameter_value_lists": [
+ ["balance_type_list","balance_type_group"],
+ ["balance_type_list","balance_type_node"],
+ ["balance_type_list","balance_type_none"]
+ ],
+ "parameter_definitions": [
+ ["connection","connection_availability_factor",1,null,"Availability of the `connection`,acting as a multiplier on its `connection_capacity`. Typically between 0-1."],
+ ["connection__node__node","connection_flow_delay",{"type": "duration","data": "0h"},null,"Delays the `connection_flows` associated with the latter `node` in respect to the `connection_flows` associated with the first `node`."],
+ ["node","balance_type","balance_type_node","balance_type_list","A selector for how the `:nodal_balance` constraint should be handled."],
+ ["node","demand",0,null,"Demand for the `commodity` of a `node`. Energy gains can be represented using negative `demand`."],
+ ["node","fix_node_state",null,null,"Fixes the corresponding `node_state` variable to the provided value. Can be used for e.g. fixing boundary conditions."],
+ ["node","has_state",null,null,"A boolean flag for whether a `node` has a `node_state` variable."],
+ ["unit__from_node","unit_capacity",null,null,"Maximum `unit_flow` capacity of a single 'sub_unit' of the `unit`."],
+ ["unit__to_node","unit_capacity",null,null,"Maximum `unit_flow` capacity of a single 'sub_unit' of the `unit`."]
+ ],
+ "parameter_values": [
+ ["connection__node__node",["Bastusel_to_Grytfors_disch","Grytfors_upper","Bastusel_lower"],"connection_flow_delay",{"type": "duration","data": "1h"},"Base"],
+ ["node","Bastusel_upper","demand",-0.2579768519,"Base"],
+ ["node","Bastusel_upper","fix_node_state",{"type": "time_series","data": {"2019-01-01T00:00:00": 5581.44,"2019-01-01T01:00:00": -1,"2019-01-07T23:00:00": 5417.28}},"Base"],
+ ["node","Bastusel_upper","has_state",null,"Base"],
+ ["unit__from_node",["Bastusel_pwr_plant","Bastusel_upper"],"unit_capacity",170,"Base"]
+ ],
+ "alternatives": [
+ ["Base","Base alternative"]
+ ]
+ }
diff --git a/docs/source/spine_db_editor/index.rst b/docs/source/spine_db_editor/index.rst
index fdad1e7f2..4117698c2 100644
--- a/docs/source/spine_db_editor/index.rst
+++ b/docs/source/spine_db_editor/index.rst
@@ -20,4 +20,5 @@ that you can use to visualize and edit data in one or more Spine databases.
removing_data
managing_data
importing_and_exporting_data
- committing_and_rolling_back
+ committing_and_history
+ vacuum
diff --git a/docs/source/spine_db_editor/managing_data.rst b/docs/source/spine_db_editor/managing_data.rst
index 19e910680..97e3fcc1a 100644
--- a/docs/source/spine_db_editor/managing_data.rst
+++ b/docs/source/spine_db_editor/managing_data.rst
@@ -1,3 +1,8 @@
+.. |add| image:: ../../../spinetoolbox/ui/resources/menu_icons/cube_plus.svg
+ :width: 16
+.. |remove| image:: ../../../spinetoolbox/ui/resources/menu_icons/cube_minus.svg
+ :width: 16
+
Managing data
-------------
@@ -7,54 +12,52 @@ This section describes the available tools to manage data, i.e., adding, updatin
.. contents::
:local:
-Managing object groups
-=======================
+Managing entity groups
+======================
-To modify object groups, expand the corresponding item in *Object tree* to display the **members** item,
-right-click on the latter and select **Manage members** from the context menu.
-The *Manage parameter tags* dialog will pop up:
+To modify entity groups, expand the corresponding entity class item in **Entity Tree** to display the group item,
+right-click on it and select **Manage members** from the context menu.
+The *Manage members* dialog will pop up:
.. image:: img/manage_members_dialog.png
:align: center
-To add new member objects, select them under *Non members*, and press the button in the middle that has a plus sign.
-To remove current member objects, select them under *Members*, and press the button in the middle that has a minus sign.
+To add new member entities, select them under *Non members*, and press the (|add|>>) button in the middle.
+To remove current members, select them under *Members*, and press the (|remove| <<) button in the middle.
Multiple selection works in both lists.
-When you're happy, press **Ok**.
-
+When you're happy with the members, press **OK**.
.. note:: Changes made using the *Manage members* dialog are not applied to
- the database until you press **Ok**.
+ the database until you press **OK**.
-Managing relationships
-======================
+Managing N-D entities
+=====================
-Select **Edit -> Manage relationships** from the menu bar.
-The *Manage relationships* dialog will pop up:
+Right click the root item, or an N-D entity item in **Entity Tree** and from the context menu select
+**Manage elements**. The *Manage elements* dialog will pop up:
-.. image:: img/manage_relationships_dialog.png
+.. image:: img/manage_entities_dialog.png
:align: center
-To get started, select a relationship class and a database from the combo boxes at the top.
-
-To add relationships, select the member objects for each class under *Available objects*
-and press the **Add relationships** button at the middle of the form.
-The relationships will appear at the top of the table under *Existing relationships*.
+To get started, select an entity class and a database from the combo boxes at the top.
-To add multiple relationships at the same time,
-select multiple objects for one or more of the classes.
+To add entities, select the elements for each class under *Available elements*
+and press the add button (|add|>>) in the middle of the form.
+The entities will appear at the top of the table under *Existing entities*.
-.. tip:: To *extend* the selection of objects for a class,
- press and hold the **Ctrl** key while clicking on more items.
+To add multiple entities at the same time,
+select multiple elements for one or more of the classes. All possible permutations
+of the selected elements will be added to *Existing entities*.
-.. note:: The set of relationships to add is determined by applying the *product*
- operation over the objects selected for each class.
+.. tip:: To *extend* the selection of entities for a class,
+ press and hold the **Ctrl** key while clicking on more items. Holding down **Shift**
+ allows to select an area of items by clicking the start and end of the selection.
-To remove relationships, select the appropriate rows under *Existing relationships*
-and press the **Remove relationships** button on the right.
+To remove entities, select the appropriate rows under *Existing entities*
+and press the remove button (|remove|) on the right.
-When you're happy with your changes, press **Ok**.
+When you're happy with your changes, press **OK**.
-.. note:: Changes made using the *Manage relationships* dialog are not applied to
- the database until you press **Ok**.
\ No newline at end of file
+.. note:: Changes made using the *Manage elements* dialog are not applied to
+ the database until you press **OK**.
diff --git a/docs/source/spine_db_editor/removing_data.rst b/docs/source/spine_db_editor/removing_data.rst
index 0c7ad6de1..c976839ea 100644
--- a/docs/source/spine_db_editor/removing_data.rst
+++ b/docs/source/spine_db_editor/removing_data.rst
@@ -1,4 +1,9 @@
+.. |purge| image:: ../../../spinetoolbox/ui/resources/menu_icons/bolt-lightning.svg
+ :width: 16
+
+.. _Removing data:
+
Removing data
-------------
@@ -7,13 +12,38 @@ This section describes the available tools to remove data.
.. contents::
:local:
+Purging items
+=============
+
+To remove all items of specific types, select **Edit -> Purge...** (|purge|) from the hamburger menu.
+The *Purge items* dialog will pop up:
+
+.. image:: img/purge_dialog.png
+ :align: center
+
+The databases that are opened in the Editor are listed under *Databases*. From there you can select
+the databases where the mass removal will take place. The *Select all* -option will check all of the
+boxes and *Deselect all* will in turn uncheck every box.
+
+The type of items that are to be deleted, need to be specified under *Items*. There are a couple of useful
+buttons in addition to the same *Select all* and *Deselect all*: *Select entity and value items* and
+*Select scenario items*. The former will select the *entity*, *entity_group*, *parameter_value*,
+*entity_metadata* and *parameter_value_metadata* items in the list. The latter will select the *alternative*,
+*scenario* and *scenario_alternative* items. When you are happy with your choices, press **Purge** to perform
+the mass removal.
+
+.. note:: The purge dialog can also be opened from the **Properties** -dock widget of a Data Store.
+
+.. tip:: Purging can also be an automated part of the workflow. See :ref:`Links` for more information
+ about purging a database automatically.
+
Removing entities and classes
=============================
-From *Object tree*, *Relationship tree*, or *Entity graph*
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+From **Entity Tree** or **Graph View**
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-Select the items in *Object tree*, *Relationship tree*, or *Entity graph*, corresponding to the entities and classes
+Select the items in **Entity Tree** or **Graph View**, corresponding to the entities and classes
you want to remove. Then, right-click on the selection and choose **Remove** from the context menu.
The *Remove items* dialog will popup:
@@ -22,41 +52,44 @@ The *Remove items* dialog will popup:
:align: center
Specify the databases from where you want to remove each item under the *databases* column,
-and press **Ok**.
-
-From *Pivot table*
-~~~~~~~~~~~~~~~~~~
-To remove objects or relationships from a specific class, bring the class to *Pivot table*
-using the **Parameter value** input type
-(see :ref:`using_pivot_table_and_frozen_table`),
-and select the cells in the table headers corresponding to the objects and/or relationships you want to remove.
-Then, right-click on the selection and choose the corresponding **Remove** option from the context menu.
-
-Alternatively, to remove relationships for a specific class,
-bring the class to *Pivot table* using the **Relationship** input type
+and press **OK**.
+
+From **Pivot View**
+~~~~~~~~~~~~~~~~~~~
+
+To remove entities from a specific class, bring the class to **Pivot View**
+using the **Value** input type (see :ref:`using_pivot_table_and_frozen_table`),
+and select the cells in the table headers corresponding to the entities you want to remove.
+Then, right-click on the selection and choose the **Remove entities** option from the context menu.
+This will remove the selected rows.
+
+
+Alternatively, to remove N-D entities of a specific class,
+bring the class to **Pivot View** using the **Element** input type
(see :ref:`using_pivot_table_and_frozen_table`).
-The *Pivot table* headers will be populated
-with all possible combinations of objects across the member classes.
-Locate the member objects of the relationship you want to remove,
+The **Pivot View** headers will be populated
+with all possible combinations of entities across the member classes.
+Locate the member entities you want to remove,
and uncheck the corresponding box in the table body.
Removing parameter definitions and values
=========================================
-From *Stacked tables*
-~~~~~~~~~~~~~~~~~~~~~
+From **Table View**
+~~~~~~~~~~~~~~~~~~~
To remove parameter definitions or values,
-go to the relevant *Stacked table* and select any cell in the row corresponding to the items
-you want to remove.s
-Then, right-click on the selection and choose the appropriate **Remove** option from the context menu.
+go to the relevant **Table View** and select any cell in the row corresponding to the items
+you want to remove. Then, right-click on the selection and select the **Remove row(s)**
+option from the context menu. Multiple selection is supported and the removal can also be
+performed by pressing **Ctrl+Del**.
-From *Pivot table*
-~~~~~~~~~~~~~~~~~~
+From **Pivot View**
+~~~~~~~~~~~~~~~~~~~
To remove parameter definitions and/or values for a certain class,
-bring the corresponding class to *Pivot table* using the **Parameter value** input type
+bring the corresponding class to **Pivot View** using the **Value** input type
(see :ref:`using_pivot_table_and_frozen_table`).
Then:
@@ -65,62 +98,41 @@ Then:
2. Select the cells in the table body corresponding to the parameter values you want to remove,
right-click on the selection and choose **Remove parameter values** from the context menu.
-Purging items
-=============
-
-To remove all items of specific types, select **Edit -> Purge** from the hamburger menu.
-The *Purge items* dialog will pop up:
-
-.. image:: img/mass_remove_items_dialog.png
- :align: center
-
-
-Select the databases from where you want to remove the items under *Databases*,
-and the type of items you want to remove under *Items*.
-Then, press **Ok**.
-
Removing alternatives
=====================
-From *Pivot table*
-~~~~~~~~~~~~~~~~~~
+From **Pivot View**
+~~~~~~~~~~~~~~~~~~~
Select the **Scenario** input type (see :ref:`using_pivot_table_and_frozen_table`).
-To remove alternatives, just edit the proper cells in the **alternative** header,
-right-click on the selection and choose **Remove** from the context menu.
+To remove alternatives, select the to be removed items in the **alternative** header,
+right-click on the selection and choose **Remove alternatives** from the context menu.
-From *Alternative tree*
-~~~~~~~~~~~~~~~~~~~~~~~
+From **Alternative**
+~~~~~~~~~~~~~~~~~~~~
-To remove an alternative, just select the corresponding items in *Alternative tree*,
+To remove an alternative, just select the corresponding items in **Alternative**,
right-click on the selection and choose **Remove** from the context menu.
Removing scenarios
==================
-From *Pivot table*
-~~~~~~~~~~~~~~~~~~
+From **Pivot View**
+~~~~~~~~~~~~~~~~~~~
Select the **Scenario** input type (see :ref:`using_pivot_table_and_frozen_table`).
To remove scenarios, just select the proper cells in the **scenario** header,
-right-click on the selection and choose **Remove** from the context menu.
+right-click on the selection and choose **Remove scenarios** from the context menu.
-From *Scenario tree*
-~~~~~~~~~~~~~~~~~~~~
+From **Scenario Tree**
+~~~~~~~~~~~~~~~~~~~~~~
-To remove a scenario, just select the corresponding items in *Scenario tree*,
-right-click on the selection and choose **Remove** from the context menu.
-
-To remove a scenario alternative, select the corresponding alternative items in *Scenario tree*,
-right-click on the selection and choose **Remove** from the context menu.
-
-Removing tools and features
-===========================
-
-To remove a feature, tool, or method, just select the corresponding items in *Tool/Feature tree*,
+To remove a scenario, just select the corresponding items in **Scenario Tree**,
right-click on the selection and choose **Remove** from the context menu.
+To remove a scenario alternative from a scenario, select the corresponding alternative items
+in **Scenario Tree**, right-click on the selection and choose **Remove** from the context menu.
Removing parameter value lists
==============================
@@ -128,16 +140,14 @@ Removing parameter value lists
To remove a parameter value list or any of its values, just select the corresponding items in *Parameter value list*,
right-click on the selection and choose **Remove** from the context menu.
-
Removing metadata
=================
-Select the corresponding items in *Metadata*, right-click on the selection and choose **Remove row(s)**
+Select the corresponding items in **Metadata**, right-click on the selection and choose **Remove row(s)**
from the context menu.
-
Removing item metadata
======================
-Select the corresponding items in *Item metadata*, right-click on the selection and choose **Remove row(s)**
+Select the corresponding items in **Item metadata**, right-click on the selection and choose **Remove row(s)**
from the context menu.
diff --git a/docs/source/spine_db_editor/spine_data_structure.rst b/docs/source/spine_db_editor/spine_data_structure.rst
index 4b4009a8a..b906d1942 100644
--- a/docs/source/spine_db_editor/spine_data_structure.rst
+++ b/docs/source/spine_db_editor/spine_data_structure.rst
@@ -5,28 +5,5 @@ Spine data structure
.. contents::
:local:
-Main features
---------------------
-
-Spine data structure follows entity-attribute-value (EAV) with classes and relationships data model
-(`Wikipedia `_).
-It is an open schema where the data structure is defined through data (and not through database structure).
-Spine Toolbox also adds an ability to hold alternative parameter values for the same parameter of a particular entity.
-This allows the creation of scenarios.
-A potential weakness of EAV is that each parameter value needs a separate row in the database which could make the parameter table large and slow.
-In Spine Toolbox this is circumvented by allowing different datatypes like time series and maps to be represented in the parameter field
-and thus greatly reducing the number of rows required to present large systems.
-
-Definitions
-===========
-
-1. Entity: an object (one dimension) or a relationship (n-dimensions)
-2. Attribute: parameter name
-3. Value: parameter value
-4. Entity class: a category for entities (e.g. 'unit' is an object class while 'coal_power_plant' is an entity of 'unit' class)
-5. Alternative: Each parameter value belongs to one alternative
-6. Scenario: Combines alternatives into a single scenario
-
-Diagram
-=======
-.. image:: img/EAV_CR.png
+Documentation for the Spine data structure can be found `here
+`_.
diff --git a/docs/source/spine_db_editor/updating_data.rst b/docs/source/spine_db_editor/updating_data.rst
index a4a821fa7..0ebf025f1 100644
--- a/docs/source/spine_db_editor/updating_data.rst
+++ b/docs/source/spine_db_editor/updating_data.rst
@@ -11,72 +11,74 @@ This section describes the available tools to update existing data.
Updating entities and classes
=============================
-From *Object tree*, *Relationship tree*, or *Entity graph*
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+From **Entity Tree** or **Graph View**
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-Select any number of entity and/or class items in *Object tree*
-or *Relationship tree*, or any number of object and/or relationship items in *Entity graph*.
+Select any number of entity and/or class items in **Entity Tree**, or any number of entity items in **Graph View**.
Then, right-click on the selection and choose **Edit...** from the context menu.
-One separate *Edit...* dialog will pop up for each selected entity or class type,
+Depending on the selections, at least one *Edit...* dialog will pop up,
and the tables will be filled with the current data of selected items. E.g.:
-.. image:: img/edit_object_classes_dialog.png
+.. image:: img/edit_entity_classes_dialog.png
:align: center
Modify the field(s) you want under the corresponding column(s).
Specify the databases where you want to update each item under the *databases* column.
-When you're ready, press **Ok**.
+When you're ready, press **OK**.
-From *Pivot table*
-~~~~~~~~~~~~~~~~~~
+From **Pivot View**
+~~~~~~~~~~~~~~~~~~~
-To rename an object of a specific class, bring the class to *Pivot table* using any input type
+To rename an entity of a specific class, bring the class to **Pivot View** using any input type
(see :ref:`using_pivot_table_and_frozen_table`).
Then, just edit the appropriate cell in the corresponding class header.
Updating parameter definitions and values
=========================================
-From *Stacked tables*
+From **Table Views**
~~~~~~~~~~~~~~~~~~~~~
-To update parameter data, just go to the appropriate *Stacked table* and edit the corresponding row.
+To update parameter data, just go to the appropriate **Table View** and edit the corresponding row.
-From *Pivot table*
-~~~~~~~~~~~~~~~~~~
+From **Pivot View**
+~~~~~~~~~~~~~~~~~~~
To rename parameter definitions for a class,
-bring the corresponding class to *Pivot table* using the **Parameter value** input type
+bring the corresponding class to **Pivot View** using the **Value** input type
(see :ref:`using_pivot_table_and_frozen_table`).
Then, just edit the appropriate cell in the **parameter** header.
-To modify parameter values for an object or relationship,
-bring the corresponding class to *Pivot table* using the **Parameter value** input type
+To modify parameter values for an entity,
+bring the corresponding class to **Pivot View** using the **Value** input type
(see :ref:`using_pivot_table_and_frozen_table`).
Then, just edit the appropriate cell in the table body.
+Updating entity alternatives
+============================
+
+To update an entity alternative, edit the corresponding row from **Entity Alternative** in **Table View**.
Updating alternatives
=====================
-
-From *Pivot table*
-~~~~~~~~~~~~~~~~~~
+From **Pivot View**
+~~~~~~~~~~~~~~~~~~~
Select the **Scenario** input type (see :ref:`using_pivot_table_and_frozen_table`).
To rename an alternative, just edit the proper cell in the **alternative** header.
-From *Alternative tree*
-~~~~~~~~~~~~~~~~~~~~~~~
+From **Alternative**
+~~~~~~~~~~~~~~~~~~~~
-To rename an alternative, just edit the appropriate item in *Alternative tree*.
+To rename an alternative, just edit the appropriate item in **Alternative**.
Updating scenarios
==================
-From *Pivot table*
-~~~~~~~~~~~~~~~~~~
+From **Pivot View**
+~~~~~~~~~~~~~~~~~~~
Select the **Scenario** input type (see :ref:`using_pivot_table_and_frozen_table`).
To rename a scenario, just edit the proper cell in the **scenario** header.
@@ -85,21 +87,15 @@ To change the alternatives of a scenario as well as their ranks,
check or uncheck the boxes on the pivot table.
The number in the checkbox signifies the alternative's rank.
-From *Scenario tree*
-~~~~~~~~~~~~~~~~~~~~
+From **Scenario Tree**
+~~~~~~~~~~~~~~~~~~~~~~
-To rename a scenario, just edit the appropriate item in *Scenario tree*.
+To rename a scenario, just edit the appropriate item in **Scenario Tree**.
To change scenario alternative ranks, just drag and drop the items under the corresponding scenario.
-
-Updating tools and features
-===========================
-
-To change a feature or method, or rename a tool, just edit the appropriate item in *Tool/Feature tree*.
-
-
Updating parameter value lists
==============================
-To rename a parameter value list or change any of its values, just edit the appropriate item in *Parameter value list*.
+To rename a parameter value list or change any of its values, just edit
+the appropriate item in **Parameter value list**.
diff --git a/docs/source/spine_db_editor/vacuum.rst b/docs/source/spine_db_editor/vacuum.rst
new file mode 100644
index 000000000..6567d57ef
--- /dev/null
+++ b/docs/source/spine_db_editor/vacuum.rst
@@ -0,0 +1,18 @@
+
+.. |broom| image:: ../../../spinetoolbox/ui/resources/menu_icons/broom.svg
+ :width: 16
+
+.. _vacuum:
+
+Vacuum
+======
+
+Vacuuming is available for Spine Databases in the SQLite format. Basically it tries to free up some unnecessary
+memory from the ``.sqlite`` -file. If you have very large databases, it might be beneficial to vacuum it once in a while.
+More detailed explanation on what vacuuming does to the SQLite database can be found
+`here `_.
+
+To vacuum a database, either press the |broom| Vacuum -button from the Data Store **Properties** -panel, or
+straight from the Spine Database Editors hamburger menu **Edit->Vacuum**.
+
+After the vacuum is finished, a message informing the amount of bytes freed from the database is shown.
diff --git a/docs/source/spine_db_editor/viewing_data.rst b/docs/source/spine_db_editor/viewing_data.rst
index ad01e89d3..974f55162 100644
--- a/docs/source/spine_db_editor/viewing_data.rst
+++ b/docs/source/spine_db_editor/viewing_data.rst
@@ -7,182 +7,222 @@ This section describes the available tools to view data.
.. contents::
:local:
-Viewing entities and classes
-============================
+Viewing entities and entity classes
+===================================
-Using *Entity trees*
-~~~~~~~~~~~~~~~~~~~~
+Using the **Entity Tree**
+~~~~~~~~~~~~~~~~~~~~~~~~~
-*Entity trees* present the structure of classes and entities in all databases in the shape of a tree:
+The **Entity Tree** presents the structure of entity classes and entities in all databases in the shape of a tree:
-.. image:: img/object_tree.png
+.. image:: img/entity_tree.png
:align: center
-In *Object tree*:
-
-- To view all object classes from all databases,
+- To view all entity classes from all databases,
expand the root item (automatically expanded when loading the form).
-- To view all objects of a class, expand the corresponding object class item.
-- To view all relationship classes involving an object class, expand any objects of that class.
-- To view all relationships of a class involving a given object,
- expand the corresponding relationship class item under the corresponding object item.
+- To view all entities of a class, expand the corresponding entity class item.
+- To view all multidimensional entities where a specific entity is an member, expand that entity.
-In *Relationship tree*:
+.. tip:: To *extend* the selection in **Entity Tree**, press and hold the **Ctrl** key
+ while clicking on the items.
-- To view all relationship classes from all databases,
- expand the root item (automatically expanded when loading the form).
-- To view all relationships of a class,
- expand the corresponding relationship class item.
+Right clicking items in the **Entity Tree** will open up a context menu. Depending on what kind of item
+the menu was opened from (root, entity class, entity, N-D entity) all of the options might not be available.
+Unavailable options are still visible but they are greyed out:
-.. note:: To expand an item in *Object tree* or *Relationship tree*,
- double-click on the item or press the right arrow while it's active.
- Items in gray don't have any children, thus they cannot be expanded.
- To collapse an expanded item, double-click on it again or press the left arrow while it's active.
+.. image:: img/entity_tree_context_menu.png
+ :align: center
-.. tip:: To expand or collapse an item and all its descendants in *Object tree* or *Relationship tree*,
- right click on the item to display the context menu, and select **Fully expand** or **Fully collapse.**
+- **Copy name(s) as text** copies the data from the selection so that it can be pasted elsewhere.
+- **Add entity classes** opens up a dialog to add new entity classes.
+- **Add entities** opens up a dialog to add new entities.
+- **Add entity group** opens up a dialog to create entity groups.
+- **Manage elements** opens up a dialog where new entities can be created or existing ones deleted for
+ a multidimensional entity.
+- **Manage members** opens up a dialog to delete or add new members in an entity group.
+- **Select superclass** opens up a dialog to set the superclass for a given entity class
-.. tip:: In *Object tree*, the same relationship appears in many places (as many as it has dimensions).
- To jump to the next occurrence of a relationship item, either double-click on the item,
- or right-click on it to display the context menu, and select **Find next**.
+- **Find next occurrence** goes to the next occurrence of the N-D entity in the **Entity Tree** and selects it.
+ This can also be done by double-clicking the item.
-Using *Entity graph*
-~~~~~~~~~~~~~~~~~~~~
+- **Edit...** opens up a dialog where the name, description, icon and active by default -setting of an
+ entity can be changed.
+- **Remove...** removes the selection.
+- **Duplicate entity** duplicates the whole entity.
-*Entity graph* presents the structure of classes and entities from one database in the shape of a graph:
+- **Export** creates a new Spine Database in an `.sqlite` file with all of the relevant data to the selection.
-.. image:: img/entity_graph.png
- :align: center
+- **Fully expand** expands the selection and all its children.
+- **Fully collapse** collapses the selection and all its children.
+- **Hide empty classes** whether to show empty classes in the tree or not.
-.. tip:: To see it in action, check out `this video `_.
+.. tip:: To expand an item in **Entity Tree**, you can also double-click on the item or press the right arrow.
+ This will only expand the next layer and leave the children expanded or collapsed depending on their previous
+ state. Items in gray don't have any children, thus they cannot be expanded. To collapse an expanded item,
+ double-click on it again or press the left arrow.
+
+.. tip:: **Entity Tree** also supports **Sticky selection**, which allows one to
+ extend the selection by clicking on items *without pressing* **Ctrl**. To enable **Sticky selection**, select
+ **Settings** from the hamburger menu, and check the corresponding box.
+
+Using the **Graph View**
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+**Graph View** presents the structure of entities from one database in the shape of a graph:
+
+.. image:: img/entity_graph.png
+ :align: center
Building the graph
******************
-To build the graph, select any number of items in either *Object tree* or *Relationship tree*.
-What is included in the graph depends on the specific selection you make:
-
-- To include all objects and relationships from the database,
- select the root item in either *Object tree* or *Relationship tree*.
-- To include all objects of a class, select the corresponding class item in *Object tree*.
-- To include all relationships of a class, select the corresponding class item in *Relationship tree*.
-- To include all relationships of a specific class involving a specific object,
- select the corresponding relationship class item under the corresponding object item in *Object tree*.
-- To include specific objects or relationships,
- select the corresponding item in either *Object tree* or *Relationship tree*.
-
-.. note:: In *Entity graph*, a small unnamed vertex represents a relationship,
- whereas a bigger named vertex represents an object. An arc between a relationship and an object
- indicates that the object is a member in that relationship.
-
-The graph automatically includes relationships whenever *all* the member objects are included
-(even if these relationships are not selected in *Object tree* or *Relationship tree*).
-You can change this behavior to automatically include relationships
-whenever *any* of the member objects are included.
-To do this, enable **Auto-expand objects** via the **Graph** menu,
-or via *Entity graph*'s context menu.
-
-.. tip:: To *extend* the selection in *Object tree* or *Relationship tree*, press and hold the **Ctrl** key
- while clicking on the items.
+To build the graph, select any number of items in the **Entity Tree**.
+What is included in the graph depends on the specific selection you make in the **Entity Tree**:
+
+- To include all entities from the database, select the root item.
+- To include all entities of an entity class, select the corresponding class item.
+- To include specific entities, select them by holding down **Ctrl**.
-.. tip:: *Object tree* and *Relationship tree* also support **Sticky selection**, which allows one to
- extend the selection by clicking on items *without pressing Ctrl*.
- To enable **Sticky selection**, select **Settings** from the hamburger menu, and check the corresponding box.
+.. note:: In **Graph View**, a small unnamed vertex represents a multidimensional entity with multiple elements,
+ whereas a bigger named vertex represents a zero dimensional entity. An arc between entities indicates that
+ the 0-D entity is an element of that N-D entity.
+
+The graph automatically includes N-D entities whenever *all* the elements of that entity are included
+(even if these entities are not selected in **Entity Tree**). You can change this behavior to automatically
+include N-D entities whenever *any* of the member elements are included. To do this, enable **Auto-expand entities**
+via the **Graph View**'s context menu.
Manipulating the graph
**********************
-You can move items in the graph by dragging them with your mouse.
-By default, each items moves individually.
-To make relationship items move along with their member objects,
-select **Settings** from the hamburger menu and check the box next to
-*Move relationships along with objects in Entity graph*.
-
-To display *Entity graph*'s context menu, just right-click on an empty space in the graph.
-
-- To save the position of items into the database,
- select the items in the graph and choose **Save positions** from the context menu.
- To clear saved positions, select the items again and choose **Clear saved positions** from the context menu.
-- To hide part of the graph, select the items you want to hide and choose **Hide** from context menu.
- To show the hidden items again, select **Show hidden** from the context menu.
-- To prune the graph, select the items you want to prune and then choose **Prune entities**
- or **Prune classes** from the context menu.
- To restore specific pruned items, display the context menu,
- hover **Restore** and select the items you want to restore from the popup menu.
- To restore all pruned items at once, select **Restore all** from the context menu.
-- To zoom in and out, scroll your mouse wheel over *Entity graph* or use **Zoom** buttons
- in the context menu.
-- To rotate clockwise or anti-clockwise, press and hold the **Shift** key while scrolling your mouse wheel,
- or use the **Rotate** buttons in the context menu.
-- To adjust the arcs' length, use the **Arc length** buttons in the context menu.
-- To rebuild the graph after moving items around, select **Rebuild graph** from the context menu.
-- To export the current graph as a PDF file, select **Export graph as PDF** from the context menu.
-
-.. note:: *Entity graph* supports extended selection and rubber-band selection.
+You can move items in the graph by dragging them with your mouse. By default, each items moves individually.
+Like in the **Design view**, multiple items can be moved at once by selecting them first.
+
+To display **Graph View**'s context menu, just right-click on an empty space in the graph.
+The context menu has the following options:
+
+- **Add entities...** opens up the add entities dialog, from where new entities can be added.
+
+- **Search** highlights the specified entities with color so that they are easier to visualize.
+
+- **Hide classes** can be used to disable all of the entities from an entity class from showing in the graph.
+ **Show** can then be used to bring back the hidden classes one by one or **Show all** to bring them all back.
+
+- **Prune classes** works like **Hide classes** but it also hides all the classes that have the specified class
+ as an element. Once again these can be brought back one by one with **Restore** or all at once with **Restore all**.
+
+- **Zoom** has three options: zoom out, zoom in and reset zoom. Using the scroll wheel of the mouse on the **Graph View**
+ also works.
+- **Arc-length** has two buttons: one for making the arcs between the entities longer and one for making them shorter.
+- **Rotate** rotates the whole graph by 15° per step. Also can be done by holding down **SHIFT** while scrolling with
+ the mouse wheel.
+
+- **Auto-expand entities** If enabled, the graph will also include entities where the selections are members besides
+ just the selections. if disabled, the graph will only show the selected entities.
+- **Merge databases** Whether to merge the databases or not.
+- **Snap entities to grid** makes it so that the placement of the entities can't be arbitrary anymore but
+ instead they can only lay on a grid.
+- **Max. entity dimension count** defines a cutoff for the number of dimensions an entity can have and still be drawn.
+- **Number of build iterations** defines the maximum numbers of iterations the layout generation algorithm can make.
+- **Minimum distance between nodes (%)** is used for setting the ideal distance between entities in the graph.
+- **Decay rate of attraction with distance** The higher this number, the lesser the attraction between distant
+ vertices when drawing the graph.
+
+- **Select graph parameters** is where different aspects of the graph can be mapped to for example parameter values.
+- **Select background image** can be used to set any `.svg` image as the background for the graph.
+
+- **Save positions** Saves the positions of the items into the database. To clear the saved position select
+ **Clear saved positions**.
+
+- **Save state...** saves the drawn graph. Selecting a specific state from **Load state...** will load that state
+ into the **Graph View**. Saved states can be deleted from **Remove state**.
+
+- **Export as image...** can be used to export the image of the graph in either `.svg` or `.pdf` formats
+- **Export as video...** can be used to export the video of the graph.
+
+- **Rebuild** to rebuild the whole graph.
+
+
+.. note:: **Graph View** supports extended selection and rubber-band selection.
To extend a selection, press and hold **Ctrl** while clicking on the items.
- To perform rubber-band selection, press and hold **Ctrl** while dragging your mouse
- around the items you want to select.
+ To perform rubber-band selection, drag your mouse around the items you want to select.
.. note:: Pruned items are remembered across graph builds.
-To display an object or relationship item's context menu, just right-click on it.
+To display an entity item's context menu, just right-click on it. The context menu has a few different options:
-- To expand or collapse relationships for an object item, hover **Expand** or **Collapse** and select the relationship
- class from the popup menu.
+- To expand or collapse N-D entities, on an entities context menu hover **Expand** or **Collapse** and select
+ the entity class from the popup menu.
+- **Connect entities** allows the creation of new N-D entities straight from the **Graph View**. When hovering over
+ the option, the list of relevant multi dimensional entity classes where the selected entity could possibly be
+ a member are shown. After selecting one of the items in the list, the entities that you want to make up the new
+ new entity in the selected entity class can be selected by clicking them in the graph. Once the selections are
+ made, a popup showing the to be added entities is shown. By default every permutation of the selections is staged
+ to be added but individual items can be also deselected.
+- **Edit**, **Remove** and **Duplicate** work as they do in the **Entity Tree**.
-Viewing parameter definitions and values
-========================================
+Viewing parameter definitions and values as well as entity alternatives
+=======================================================================
-Using *Stacked tables*
-~~~~~~~~~~~~~~~~~~~~~~
+Using **Table Views**
+~~~~~~~~~~~~~~~~~~~~~
-*Stacked tables* present object and relationship parameter data from all databases in the form of stacked tables:
+**Table View**'s: *Parameter value*, *Parameter definition* and *Entity alternative* present entity data
+from all databases in the form of tables:
-.. image:: img/object_parameter_value_table.png
+.. image:: img/entity_parameter_value_table.png
:align: center
-To filter *Stacked tables* by any entities and/or classes,
-select the corresponding items in either *Object tree*, *Relationship tree*, or *Entity graph*.
-To remove all these filters, select the root item in either *Object tree* or *Relationship tree*.
+To filter a **Table View** by any entities and/or classes,
+select the corresponding items in either **Entity Tree** or **Graph View**.
+To remove all these filters, select the root item in **Entity Tree**.
-*Stacked tables* can also be filtered by selecting alternatives or scenarios from *Alternative tree*
-and *Scenario tree*. This filter is orthogonal to the entity/class filter and can be used together with it.
-To remove all these filters, select the root items or deselect all items from *Alternative tree* and *Scenario tree*.
+A **Table View** can also be filtered by selecting alternatives or scenarios from **Alternative**
+and **Scenario tree**. This filter is orthogonal to the entity/class filter and can be used together with it.
+To remove all these filters, simply select the root item in **Entity Tree** or deselect all items from
+**Alternative** and **Scenario tree**.
-All the filters described above can be cleared with the *Clear all filters* item available in the *Stacked tables*
-right-click context menu.
+All the filters described above can be cleared with the *Clear all filters* item available in the right-click
+context menu of the **Table View**.
-To apply a custom filter on a *Stacked table*, click on any horizontal header.
+To apply a custom filter on a **Table View**, click on any horizontal header.
A menu will pop up listing the items in the corresponding column:
-.. image:: img/object_name_filter_menu.png
+.. image:: img/entity_name_filter_menu.png
:align: center
Uncheck the items you don't want to see in the table and press **Ok**.
Additionally, you can type in the search bar at the top of the menu to filter the list of items.
To remove the current filter, select **Remove filters**.
-To filter a *Stacked table* according to a selection of items in the table itself,
-right-click on the selection to show the context menu,
-and then select **Filter by** or **Filter excluding**.
-To remove these filters, select **Remove filters** from the header menus of the filtered columns.
+To filter a **Table View** according to a selection of items in the table itself, right-click on the selection
+to show the context menu, and then select **Filter by** or **Filter excluding**. To remove these filters, select
+**Remove filters** from the header menus of the filtered columns.
-.. tip:: You can rearrange columns in *Stacked tables* by dragging the headers with your mouse.
+.. tip:: You can rearrange columns in *Table Views* by dragging the headers with your mouse.
The ordering will be remembered the next time you open Spine DB editor.
-Viewing parameter values and relationships
-==========================================
+**Entity alternative**
+~~~~~~~~~~~~~~~~~~~~~~
+
+Entity alternative provides a way to set which entities are active and which are not in each alternative:
+
+.. image:: img/entity_alternative_table.png
+ :align: center
+
+Viewing parameter values and multidimensional entities
+======================================================
.. _using_pivot_table_and_frozen_table:
-Using *Pivot table* and *Frozen table*
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Using **Pivot View** and **Frozen Table**
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-*Pivot table* and *Frozen table* present data for an individual class from one database in the form of a pivot table,
+**Pivot View** and **Frozen Table** present data for an individual class from one database in the form of a pivot table,
optionally with frozen dimensions:
@@ -190,29 +230,29 @@ optionally with frozen dimensions:
:align: center
To populate the tables with data for a certain class,
-just select the corresponding class item in either *Object tree* or *Relationship tree*.
+just select the corresponding class item in **Entity Tree**.
Selecting the input type
************************
-*Pivot table* and *Frozen table* support four different input types:
+**Pivot View** and **Frozen Table** support four different input types:
-- **Parameter value** (the default): it shows objects, parameter definitions, alternatives, and databases in the headers,
+- **Value** (the default): it shows entities, parameter definitions, alternatives, and databases in the headers,
and corresponding parameter values in the table body.
-- **Index expansion**: Similar to the above, but it also shows parameter indexes in the headers.
+- **Index**: Similar to the above, but it also shows parameter indexes in the headers.
Indexes are extracted from special parameter values, such as time-series.
-- **Relationship**: it shows objects, and databases in the headers, and corresponding relationships in the table body.
- It only works when selecting a relationship class in *Relationship tree*.
-- **Scenario**: it shows scenarios, alternatives, and databases in the header, and corresponding *rank* in the table body.
+- **Element**: it shows entities, and databases in the headers, and corresponding multidimensional entities
+ in the table body. It only works when a N-D entity is selected in the **Entity Tree**.
+- **Scenario**: it shows scenarios, alternatives, and databases in the header, and corresponding *rank*
+ in the table body.
You can select the input type from the **Pivot** section in the hamburger menu.
-.. note:: In *Pivot table*,
- header blocks in the top-left area indicate what is shown in each horizontal and vertical header.
- For example, in **Parameter value** input type, by default,
- the horizontal header has two rows, listing alternative and parameter names, respectively;
- whereas the vertical header has one or more columns listing object names.
+.. note:: In **Pivot View**, header blocks in the top-left area indicate what is shown in each horizontal
+ and vertical header. For example, in **Value** input type, by default, the horizontal header
+ has two rows, listing alternative and parameter names, respectively; whereas the vertical header has
+ one or more columns listing entity names.
Pivoting and freezing
@@ -222,32 +262,35 @@ To pivot the data, drag a header block across the top-left area of the table.
You can turn a horizontal header into a vertical header and vice versa,
as well as rearrange headers vertically or horizontally.
-To freeze a dimension, drag the corresponding header block from *Pivot table* into *Frozen table*.
+To freeze a dimension, drag the corresponding header block from **Pivot View** into **Frozen table**.
To unfreeze a frozen dimension, just do the opposite.
.. note:: Your pivoting and freezing selections for any class will be remembered when switching to another class.
+.. tip:: If you are not seeing the data you think you should be seeing, it might be because there is
+ some selection active in the **Frozen Table** that is filtering those values out of the **Pivot View**.
+
Filtering
*********
-To apply a custom filter on *Pivot table*, click on the arrow next to the name of any header block.
+To apply a custom filter on **Pivot View**, click on the arrow next to the name of any header block.
A menu will pop up listing the items in the corresponding row or column:
-.. image:: img/object_name_filter_menu.png
+.. image:: img/entity_name_filter_menu.png
:align: center
Uncheck the items you don't want to see in the table and press **Ok**.
Additionally, you can type in the search bar at the top of the menu to filter the list of items.
To remove the current filter, select **Remove filters**.
-To filter the pivot table by an individual vector across the frozen dimensions,
-select the corresponding row in *Frozen table*.
+To filter the **Pivot View** by an individual vector across the frozen dimensions,
+select the corresponding row in **Frozen Table**.
Viewing alternatives and scenarios
==================================
-You can find alternatives from all databases under *Alternative tree*:
+You can find alternatives from all databases under **Alternative**:
.. image:: img/alternative_tree.png
:align: center
@@ -258,7 +301,7 @@ expand the root item for that database.
Viewing scenarios
=================
-You can find scenarios from all databases under *Scenario tree*:
+You can find scenarios from all databases under **Scenario tree**:
.. image:: img/scenario_tree.png
:align: center
@@ -268,28 +311,10 @@ expand the root item for that database.
To view the alternatives for a particular scenario,
expand the corresponding scenario item.
-Viewing tools and features
-==========================
-
-You can find tools, features, and methods from all databases under *Tool/Feature tree*:
-
-.. image:: img/tool_feature_tree.png
- :align: center
-
-To view the features and tools from each database,
-expand the root item for that database.
-To view all features, expand the **feature** item.
-To view all tools, expand the **tool** item.
-To view the features for a particular tool, expand the **tool_feature** item under the corresponding
-tool item.
-To view the methods for a particular tool-feature, expand the **tool_feature_method** item under the corresponding
-tool-feature item.
-
-
Viewing parameter value lists
=============================
-You can find parameter value lists from all databases under *Parameter value list*:
+You can find parameter value lists from all databases under **Parameter value list**:
.. image:: img/parameter_value_list.png
:align: center
@@ -302,7 +327,7 @@ To view the values for each list, expand the corresponding list item.
Viewing metadata
================
-You can find metadata from all databases under *Metadata*:
+You can find metadata from all databases under **Metadata**:
.. image:: img/metadata.png
:align: center
@@ -312,7 +337,7 @@ See also :ref:`Metadata description`.
Viewing item metadata
=====================
-You can find metadata for currently selected entities or parameter values under *Item metadata*:
+You can find metadata for currently selected entities or parameter values under **Item metadata**:
.. image:: img/item_metadata.png
:align: center
diff --git a/docs/source/spine_engine_server.rst b/docs/source/spine_engine_server.rst
index 1a2e1a837..6f2e00ccc 100644
--- a/docs/source/spine_engine_server.rst
+++ b/docs/source/spine_engine_server.rst
@@ -23,9 +23,9 @@ Here is a list of items that you should be aware of when running projects on Spi
- **Python Jupyter Console**. Kernel spec setting in Tool Specification Editor is ignored. Jupyter Console is
launched using the **python3** kernel spec. This must be installed before the server is started. See instructions
below.
-- **Julia Basic Console**. Interpreter setting in app settings (Tools page in File->Settings) is ignored. Basic
- Console runs the Julia that is found in PATH. See installation instructions below.
-- **Julia Jupyter Console**. Kernel spec setting in app settings (Tools page in File->Settings) is ignored. Jupyter
+- **Julia Basic Console**. Julia executable setting in Tool Specification Editor is ignored. Basic
+ Console runs the Julia that is found in the server machine's PATH. See installation instructions below.
+- **Julia Jupyter Console**. Kernel spec setting in Tool Specification Editor is ignored. Jupyter
Console is launched using the **julia-1.8** kernel spec. This must be installed before the server is started.
See instructions below.
diff --git a/docs/source/terminology.rst b/docs/source/terminology.rst
index 7111f8902..de1f24ecb 100644
--- a/docs/source/terminology.rst
+++ b/docs/source/terminology.rst
@@ -12,7 +12,7 @@ Here is a list of definitions related to Spine project, SpineOpt.jl, and Spine T
- **Arc** Graph theory term. See *Connection*.
- **Case study** Spine project has 13 case studies that help to improve, validate and deploy
different aspects of the SpineOpt.jl and Spine Toolbox.
-- **Connection** an arrow on Spine Toolbox Design View that is used to connect project items
+- **Connection** (aka **Arrow**) an arrow on Spine Toolbox Design View that is used to connect project items
to each other to form a DAG.
- **Data Connection** is a project item used to store a collection of data files that may or may not
be in Spine data format. It facilitates data transfer from original data sources e.g. spreadsheet
@@ -36,10 +36,14 @@ Here is a list of definitions related to Spine project, SpineOpt.jl, and Spine T
vertices and edges. In Spine Toolbox, we use project items as vertices and connections as edges to
build a DAG that represents a data processing chain (workflow).
- **Edge** Graph theory term. See *Connection*
-- **GdxExporter** is a project item that allows exporting a Spine data structure from a Data Store into a
- .gdx file which can be used as an input file in a Tool.
+- **Element** is what the entities making up a multi dimensional entity are called. See also multidimensional
+ entity.
- **Importer** is a project item that can be used to import data from e.g. an Excel file, transform it
to Spine data structure, and into a Data Store.
+- **Loop** (aka **jump**) is a special sort of connection which only connects the two attached project
+ items if the user defined loop condition is met.
+- **Multidimensional entity/entity class** (aka N-D entity/class) An entity/entity class that consists of multiple
+ other entities that are as it's members. Acts just like any other entity/entity class.
- **Node** Graph theory term. See *Project item*.
- **Predecessor** Graph theory term that is also used in Spine Toolbox. Preceding project
items of a certain project item in a DAG. For example, in DAG *x->y->z*, nodes *x* and *y* are
diff --git a/docs/source/tool_specification_editor.rst b/docs/source/tool_specification_editor.rst
index 23df8ea33..0f462fcfa 100644
--- a/docs/source/tool_specification_editor.rst
+++ b/docs/source/tool_specification_editor.rst
@@ -19,6 +19,12 @@ Tool Specification Editor
This section describes how to make a new Tool specification and how to edit existing Tool specifications.
+.. contents::
+ :local:
+
+General
+-------
+
To execute a Julia, Python, GAMS, or an executable script in Spine Toolbox, you must first create a Tool
specification for your project. You can open the Tool specification editor in several ways.
One way is to press the arrow next to the Tool icon in the toolbar to expand the Tool specifications,
@@ -131,3 +137,39 @@ context-menu.
You are now ready to execute the Tool specification in Spine Toolbox. You just need to select a Tool item in the
**Design view**, set the specification *Example Tool specification* for it, and click |play-all| or |play-selected|
button.
+
+Input & Output Files in Practice
+--------------------------------
+
+The file names can be either hard coded or not. For example, you could have a script that requires (hard coded
+in the script) a file `input.dat` and optionally works with a bunch of files that are expected to have the
+`.csv` extension. In that case you would define
+
+- `input.dat` as an Input file
+- `*.csv` as Optional input files
+
+The *Output files* work similarly; you can hard code the entire file name or use wildcards for *Optional output files*.
+
+When specifying the *Input* and *Output files* in the Specification editor, Toolbox will copy the files to the Tool's
+work directory when the Tool is executed, so they are available for the script in a known location. Note, that you
+can specify subdirectories for the files as well. These will be relative to the work directory.
+
+These options expect some level of hard-coding: file names, or at least file extensions as well as relative
+locations to the work directory need to be known when writing the Tool Spec script.
+
+There is another, more general way to provide *Input files* to the script that does not require any kind of hard
+coding: *command line arguments*. You can set them up in **Tool's Properties** tab. For example, in the project
+below, a Data connection provides *Input files* for the workflow. The files are visible in the
+*Available resources list* in **Tool's Properties** and they have been *dragged and dropped* into the the Tool
+arguments list.
+
+.. image:: img/using_input_output_files_in_tool_scripts.png
+ :align: center
+
+Now, the Python script can access the files using something like::
+
+ import sys
+ file_path1 = sys.argv[1]
+ file_path2 = sys.argv[2]
+
+Of course, more serious scripts would use the `argparse` module.
diff --git a/execution_tests/.gitignore b/execution_tests/.gitignore
new file mode 100644
index 000000000..e587db861
--- /dev/null
+++ b/execution_tests/.gitignore
@@ -0,0 +1 @@
+project_package.zip
diff --git a/execution_tests/active_by_default/.gitignore b/execution_tests/active_by_default/.gitignore
new file mode 100644
index 000000000..cacf63d26
--- /dev/null
+++ b/execution_tests/active_by_default/.gitignore
@@ -0,0 +1,3 @@
+.spinetoolbox/local/
+.spinetoolbox/items/
+*.bak*
diff --git a/execution_tests/active_by_default/.spinetoolbox/project.json b/execution_tests/active_by_default/.spinetoolbox/project.json
new file mode 100644
index 000000000..fc67c0b82
--- /dev/null
+++ b/execution_tests/active_by_default/.spinetoolbox/project.json
@@ -0,0 +1,79 @@
+{
+ "project": {
+ "version": 13,
+ "description": "",
+ "settings": {
+ "enable_execute_all": true
+ },
+ "specifications": {
+ "Exporter": [
+ {
+ "type": "path",
+ "relative": true,
+ "path": ".spinetoolbox/specifications/Exporter/export_entities.json"
+ }
+ ]
+ },
+ "connections": [
+ {
+ "name": "from Test data to Exporter",
+ "from": [
+ "Test data",
+ "right"
+ ],
+ "to": [
+ "Exporter",
+ "left"
+ ],
+ "options": {
+ "require_scenario_filter": true
+ },
+ "filter_settings": {
+ "known_filters": {
+ "db_url@Test data": {
+ "scenario_filter": {
+ "base_scenario": true
+ }
+ }
+ },
+ "auto_online": true
+ }
+ }
+ ],
+ "jumps": []
+ },
+ "items": {
+ "Test data": {
+ "type": "Data Store",
+ "description": "",
+ "x": -115.31149147072384,
+ "y": 22.059589672660213,
+ "url": {
+ "dialect": "sqlite",
+ "host": "",
+ "port": "",
+ "database": {
+ "type": "path",
+ "relative": true,
+ "path": "Test data.sqlite"
+ },
+ "schema": ""
+ }
+ },
+ "Exporter": {
+ "type": "Exporter",
+ "description": "",
+ "x": 17.046046565237432,
+ "y": 22.05958967266021,
+ "output_time_stamps": false,
+ "cancel_on_error": true,
+ "output_labels": [
+ {
+ "in_label": "db_url@Test data",
+ "out_label": "data.csv"
+ }
+ ],
+ "specification": "Export entities"
+ }
+ }
+}
\ No newline at end of file
diff --git a/execution_tests/active_by_default/.spinetoolbox/specifications/Exporter/export_entities.json b/execution_tests/active_by_default/.spinetoolbox/specifications/Exporter/export_entities.json
new file mode 100644
index 000000000..2ad5fc8c5
--- /dev/null
+++ b/execution_tests/active_by_default/.spinetoolbox/specifications/Exporter/export_entities.json
@@ -0,0 +1,25 @@
+{
+ "item_type": "Exporter",
+ "output_format": "csv",
+ "name": "Export entities",
+ "description": "",
+ "mappings": {
+ "Mapping (1)": {
+ "type": "entities",
+ "mapping": [
+ {
+ "map_type": "EntityClass",
+ "position": 0
+ },
+ {
+ "map_type": "Entity",
+ "position": 1
+ }
+ ],
+ "enabled": true,
+ "always_export_header": true,
+ "group_fn": "no_group",
+ "use_fixed_table_name": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/execution_tests/active_by_default/__init__.py b/execution_tests/active_by_default/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/execution_tests/active_by_default/execution_test.py b/execution_tests/active_by_default/execution_test.py
new file mode 100644
index 000000000..f93b3adb3
--- /dev/null
+++ b/execution_tests/active_by_default/execution_test.py
@@ -0,0 +1,49 @@
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import unittest
+from spinedb_api import DatabaseMapping
+
+class ActiveByDefault(unittest.TestCase):
+ _root_path = Path(__file__).parent
+ _database_path = _root_path / "Test data.sqlite"
+ _exporter_output_path = _root_path / ".spinetoolbox" / "items" / "exporter" / "output"
+
+ def _check_addition(self, result):
+ error = result[1]
+ self.assertIsNone(error)
+
+ def setUp(self):
+ if self._exporter_output_path.exists():
+ shutil.rmtree(self._exporter_output_path)
+ self._database_path.parent.mkdir(parents=True, exist_ok=True)
+ if self._database_path.exists():
+ self._database_path.unlink()
+ url = "sqlite:///" + str(self._database_path)
+ with DatabaseMapping(url, create=True) as db_map:
+ self._check_addition(db_map.add_entity_class_item(name="HiddenByDefault", active_by_default=False))
+ self._check_addition(db_map.add_entity_item(name="hidden", entity_class_name="HiddenByDefault"))
+ self._check_addition(db_map.add_entity_class_item(name="VisibleByDefault", active_by_default=True))
+ self._check_addition(db_map.add_entity_item(name="visible", entity_class_name="VisibleByDefault"))
+ self._check_addition(db_map.add_scenario_item(name="base_scenario"))
+ self._check_addition(db_map.add_scenario_alternative_item(scenario_name="base_scenario", alternative_name="Base", rank=0))
+ db_map.commit_session("Add test data.")
+
+ def test_execution(self):
+ this_file = Path(__file__)
+ completed = subprocess.run((sys.executable, "-m", "spinetoolbox", "--execute-only", str(this_file.parent)))
+ self.assertEqual(completed.returncode, 0)
+ self.assertTrue(self._exporter_output_path.exists())
+ self.assertEqual(len(list(self._exporter_output_path.iterdir())), 1)
+ output_dir = next(iter(self._exporter_output_path.iterdir()))
+ filter_id = (output_dir / ".filter_id").read_text(encoding="utf-8").splitlines()
+ self.assertEqual(len(filter_id), 1)
+ self.assertEqual(filter_id, ["base_scenario - Test data"])
+ entities = (output_dir / "data.csv").read_text(encoding="utf-8").splitlines()
+ expected = ["VisibleByDefault,visible"]
+ self.assertCountEqual(entities, expected)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/execution_tests/alternative_filters/.gitignore b/execution_tests/alternative_filters/.gitignore
new file mode 100644
index 000000000..cacf63d26
--- /dev/null
+++ b/execution_tests/alternative_filters/.gitignore
@@ -0,0 +1,3 @@
+.spinetoolbox/local/
+.spinetoolbox/items/
+*.bak*
diff --git a/execution_tests/alternative_filters/.spinetoolbox/project.json b/execution_tests/alternative_filters/.spinetoolbox/project.json
new file mode 100644
index 000000000..0a782cfc0
--- /dev/null
+++ b/execution_tests/alternative_filters/.spinetoolbox/project.json
@@ -0,0 +1,83 @@
+{
+ "project": {
+ "version": 13,
+ "description": "",
+ "settings": {
+ "enable_execute_all": true
+ },
+ "specifications": {
+ "Exporter": [
+ {
+ "type": "path",
+ "relative": true,
+ "path": ".spinetoolbox/specifications/Exporter/export_values.json"
+ }
+ ]
+ },
+ "connections": [
+ {
+ "name": "from Data Store to Exporter",
+ "from": [
+ "Data Store",
+ "right"
+ ],
+ "to": [
+ "Exporter",
+ "left"
+ ],
+ "filter_settings": {
+ "known_filters": {
+ "db_url@Data Store": {
+ "scenario_filter": {},
+ "alternative_filter": {
+ "Base": true,
+ "alt1": false,
+ "alt2": true
+ }
+ }
+ },
+ "auto_online": true,
+ "enabled_filter_types": {
+ "alternative_filter": true,
+ "scenario_filter": false
+ }
+ }
+ }
+ ],
+ "jumps": []
+ },
+ "items": {
+ "Data Store": {
+ "type": "Data Store",
+ "description": "",
+ "x": -251.50464049730488,
+ "y": -53.10655755520781,
+ "url": {
+ "dialect": "sqlite",
+ "host": "",
+ "port": "",
+ "database": {
+ "type": "path",
+ "relative": true,
+ "path": ".spinetoolbox/items/data_store/Data Store.sqlite"
+ },
+ "schema": ""
+ }
+ },
+ "Exporter": {
+ "type": "Exporter",
+ "description": "",
+ "x": -103.20708355068687,
+ "y": -53.10655755520782,
+ "output_time_stamps": false,
+ "cancel_on_error": true,
+ "output_labels": [
+ {
+ "in_label": "db_url@Data Store",
+ "out_label": "out.dat"
+ }
+ ],
+ "specification": "Export values"
+ }
+ }
+}
\ No newline at end of file
diff --git a/execution_tests/alternative_filters/.spinetoolbox/specifications/Exporter/export_values.json b/execution_tests/alternative_filters/.spinetoolbox/specifications/Exporter/export_values.json
new file mode 100644
index 000000000..bff2d39b1
--- /dev/null
+++ b/execution_tests/alternative_filters/.spinetoolbox/specifications/Exporter/export_values.json
@@ -0,0 +1,46 @@
+{
+ "item_type": "Exporter",
+ "output_format": "csv",
+ "name": "Export values",
+ "description": "",
+ "mappings": {
+ "Mapping (1)": {
+ "type": "entity_parameter_values",
+ "mapping": [
+ {
+ "map_type": "EntityClass",
+ "position": 0
+ },
+ {
+ "map_type": "ParameterDefinition",
+ "position": 1
+ },
+ {
+ "map_type": "ParameterValueList",
+ "position": "hidden",
+ "ignorable": true
+ },
+ {
+ "map_type": "Entity",
+ "position": 2
+ },
+ {
+ "map_type": "Alternative",
+ "position": 3
+ },
+ {
+ "map_type": "ParameterValueType",
+ "position": "hidden"
+ },
+ {
+ "map_type": "ParameterValue",
+ "position": 4
+ }
+ ],
+ "enabled": true,
+ "always_export_header": true,
+ "group_fn": "no_group",
+ "use_fixed_table_name": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/execution_tests/alternative_filters/__init__.py b/execution_tests/alternative_filters/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/execution_tests/alternative_filters/execution_test.py b/execution_tests/alternative_filters/execution_test.py
new file mode 100644
index 000000000..f209203bd
--- /dev/null
+++ b/execution_tests/alternative_filters/execution_test.py
@@ -0,0 +1,77 @@
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import unittest
+from spinedb_api import DatabaseMapping, to_database
+
+
+class AlternativeFilters(unittest.TestCase):
+ _root_path = Path(__file__).parent
+ _database_path = _root_path / ".spinetoolbox" / "items" / "data_store" / "Data Store.sqlite"
+ _exporter_output_file_path = _root_path / ".spinetoolbox" / "items" / "exporter" / "output" / "out.dat"
+
+ def _check_addition(self, result):
+ error = result[1]
+ self.assertIsNone(error)
+
+ def setUp(self):
+ if self._exporter_output_file_path.exists():
+ shutil.rmtree(self._exporter_output_file_path.parent)
+ self._database_path.parent.mkdir(parents=True, exist_ok=True)
+ if self._database_path.exists():
+ self._database_path.unlink()
+ url = "sqlite:///" + str(self._database_path)
+ with DatabaseMapping(url, create=True) as db_map:
+ self._check_addition(db_map.add_entity_class_item(name="Widget"))
+ self._check_addition(db_map.add_entity_item(name="gadget", entity_class_name="Widget"))
+ self._check_addition(db_map.add_parameter_definition_item(name="measurable", entity_class_name="Widget"))
+ self._check_addition(db_map.add_alternative_item(name="alt1"))
+ self._check_addition(db_map.add_alternative_item(name="alt2"))
+ value, value_type = to_database(2.0)
+ self._check_addition(
+ db_map.add_parameter_value_item(
+ entity_class_name="Widget",
+ entity_byname=("gadget",),
+ parameter_definition_name="measurable",
+ alternative_name="Base",
+ value=value,
+ type=value_type,
+ )
+ )
+ value, value_type = to_database(22.0)
+ self._check_addition(
+ db_map.add_parameter_value_item(
+ entity_class_name="Widget",
+ entity_byname=("gadget",),
+ parameter_definition_name="measurable",
+ alternative_name="alt1",
+ value=value,
+ type=value_type,
+ )
+ )
+ value, value_type = to_database(222.0)
+ self._check_addition(
+ db_map.add_parameter_value_item(
+ entity_class_name="Widget",
+ entity_byname=("gadget",),
+ parameter_definition_name="measurable",
+ alternative_name="alt2",
+ value=value,
+ type=value_type,
+ )
+ )
+ db_map.commit_session("Add test data.")
+
+ def test_execution(self):
+ this_file = Path(__file__)
+ completed = subprocess.run((sys.executable, "-m", "spinetoolbox", "--execute-only", str(this_file.parent)))
+ self.assertEqual(completed.returncode, 0)
+ self.assertTrue(self._exporter_output_file_path.exists())
+ entities = self._exporter_output_file_path.read_text(encoding="utf-8").splitlines()
+ expected = ["Widget,measurable,gadget,Base,2.0", "Widget,measurable,gadget,alt2,222.0"]
+ self.assertCountEqual(entities, expected)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/execution_tests/export_entities_with_entity_alternatives/.gitignore b/execution_tests/export_entities_with_entity_alternatives/.gitignore
new file mode 100644
index 000000000..cacf63d26
--- /dev/null
+++ b/execution_tests/export_entities_with_entity_alternatives/.gitignore
@@ -0,0 +1,3 @@
+.spinetoolbox/local/
+.spinetoolbox/items/
+*.bak*
diff --git a/execution_tests/export_entities_with_entity_alternatives/.spinetoolbox/project.json b/execution_tests/export_entities_with_entity_alternatives/.spinetoolbox/project.json
new file mode 100644
index 000000000..7664d6e9d
--- /dev/null
+++ b/execution_tests/export_entities_with_entity_alternatives/.spinetoolbox/project.json
@@ -0,0 +1,78 @@
+{
+ "project": {
+ "version": 13,
+ "description": "",
+ "settings": {
+ "enable_execute_all": true
+ },
+ "specifications": {
+ "Exporter": [
+ {
+ "type": "path",
+ "relative": true,
+ "path": ".spinetoolbox/specifications/Exporter/entities.json"
+ }
+ ]
+ },
+ "connections": [
+ {
+ "name": "from Source to Export entities",
+ "from": [
+ "Source",
+ "right"
+ ],
+ "to": [
+ "Export entities",
+ "left"
+ ],
+ "options": {
+ "require_scenario_filter": true
+ },
+ "filter_settings": {
+ "known_filters": {
+ "db_url@Source": {
+ "scenario_filter": {
+ "scenario": true
+ }
+ }
+ },
+ "auto_online": true
+ }
+ }
+ ],
+ "jumps": []
+ },
+ "items": {
+ "Source": {
+ "type": "Data Store",
+ "description": "",
+ "x": -130.22135416666669,
+ "y": 20.03405448717949,
+ "url": {
+ "dialect": "sqlite",
+ "host": "",
+ "port": "",
+ "database": {
+ "type": "path",
+ "relative": true,
+ "path": ".spinetoolbox/items/source/Source.sqlite"
+ }
+ }
+ },
+ "Export entities": {
+ "type": "Exporter",
+ "description": "",
+ "x": 33.05618990384616,
+ "y": 20.03405448717949,
+ "output_time_stamps": false,
+ "cancel_on_error": true,
+ "output_labels": [
+ {
+ "in_label": "db_url@Source",
+ "out_label": "entities"
+ }
+ ],
+ "specification": "entities"
+ }
+ }
+}
\ No newline at end of file
diff --git a/execution_tests/export_entities_with_entity_alternatives/.spinetoolbox/specifications/Exporter/entities.json b/execution_tests/export_entities_with_entity_alternatives/.spinetoolbox/specifications/Exporter/entities.json
new file mode 100644
index 000000000..c6a4eb43d
--- /dev/null
+++ b/execution_tests/export_entities_with_entity_alternatives/.spinetoolbox/specifications/Exporter/entities.json
@@ -0,0 +1,25 @@
+{
+ "item_type": "Exporter",
+ "output_format": "csv",
+ "name": "entities",
+ "description": "",
+ "mappings": {
+ "Mapping (1)": {
+ "type": "entities",
+ "mapping": [
+ {
+ "map_type": "EntityClass",
+ "position": 0
+ },
+ {
+ "map_type": "Entity",
+ "position": 1
+ }
+ ],
+ "enabled": true,
+ "always_export_header": true,
+ "group_fn": "no_group",
+ "use_fixed_table_name": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/execution_tests/export_entities_with_entity_alternatives/__init__.py b/execution_tests/export_entities_with_entity_alternatives/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/execution_tests/export_entities_with_entity_alternatives/execution_test.py b/execution_tests/export_entities_with_entity_alternatives/execution_test.py
new file mode 100644
index 000000000..41dbdef87
--- /dev/null
+++ b/execution_tests/export_entities_with_entity_alternatives/execution_test.py
@@ -0,0 +1,124 @@
+import subprocess
+import sys
+from pathlib import Path
+import shutil
+import unittest
+
+from spinedb_api import DatabaseMapping
+
+
+class ExportEntitiesWithEntityAlternatives(unittest.TestCase):
+ _root_path = Path(__file__).parent
+ _database_path = _root_path / ".spinetoolbox" / "items" / "source" / "Source.sqlite"
+ _exporter_output_path = _root_path / ".spinetoolbox" / "items" / "export_entities" / "output"
+
+ def setUp(self):
+ if self._exporter_output_path.exists():
+ shutil.rmtree(self._exporter_output_path)
+ self._database_path.parent.mkdir(parents=True, exist_ok=True)
+ if self._database_path.exists():
+ self._database_path.unlink()
+ url = "sqlite:///" + str(self._database_path)
+ with DatabaseMapping(url, create=True) as db_map:
+ self._assert_item_added(db_map.add_entity_class_item(name="Object", active_by_default=True))
+ self._assert_item_added(db_map.add_entity_item(entity_class_name="Object", name="none_none"))
+ self._assert_item_added(db_map.add_entity_item(entity_class_name="Object", name="true_none"))
+ self._assert_item_added(db_map.add_entity_item(entity_class_name="Object", name="none_true"))
+ self._assert_item_added(db_map.add_entity_item(entity_class_name="Object", name="true_true"))
+ self._assert_item_added(db_map.add_entity_item(entity_class_name="Object", name="false_true"))
+ self._assert_item_added(db_map.add_entity_item(entity_class_name="Object", name="true_false"))
+ self._assert_item_added(db_map.add_entity_item(entity_class_name="Object", name="false_false"))
+ self._assert_item_added(db_map.add_entity_item(entity_class_name="Object", name="none_false"))
+ self._assert_item_added(db_map.add_entity_item(entity_class_name="Object", name="false_none"))
+ self._assert_item_added(db_map.add_scenario_item(name="scenario"))
+ self._assert_item_added(db_map.add_alternative_item(name="first"))
+ self._assert_item_added(db_map.add_alternative_item(name="second"))
+ self._assert_item_added(
+ db_map.add_scenario_alternative_item(scenario_name="scenario", alternative_name="first", rank=0)
+ )
+ self._assert_item_added(
+ db_map.add_scenario_alternative_item(scenario_name="scenario", alternative_name="second", rank=1)
+ )
+ self._assert_item_added(
+ db_map.add_entity_alternative_item(
+ entity_class_name="Object", entity_byname=("true_none",), alternative_name="first", active=True
+ )
+ )
+ self._assert_item_added(
+ db_map.add_entity_alternative_item(
+ entity_class_name="Object", entity_byname=("none_true",), alternative_name="second", active=True
+ )
+ )
+ self._assert_item_added(
+ db_map.add_entity_alternative_item(
+ entity_class_name="Object", entity_byname=("true_true",), alternative_name="first", active=True
+ )
+ )
+ self._assert_item_added(
+ db_map.add_entity_alternative_item(
+ entity_class_name="Object", entity_byname=("true_true",), alternative_name="second", active=True
+ )
+ )
+ self._assert_item_added(
+ db_map.add_entity_alternative_item(
+ entity_class_name="Object", entity_byname=("false_true",), alternative_name="first", active=False
+ )
+ )
+ self._assert_item_added(
+ db_map.add_entity_alternative_item(
+ entity_class_name="Object", entity_byname=("false_true",), alternative_name="second", active=True
+ )
+ )
+ self._assert_item_added(
+ db_map.add_entity_alternative_item(
+ entity_class_name="Object", entity_byname=("true_false",), alternative_name="first", active=True
+ )
+ )
+ self._assert_item_added(
+ db_map.add_entity_alternative_item(
+ entity_class_name="Object", entity_byname=("true_false",), alternative_name="second", active=False
+ )
+ )
+ self._assert_item_added(
+ db_map.add_entity_alternative_item(
+ entity_class_name="Object", entity_byname=("false_false",), alternative_name="first", active=False
+ )
+ )
+ self._assert_item_added(
+ db_map.add_entity_alternative_item(
+ entity_class_name="Object", entity_byname=("false_false",), alternative_name="second", active=False
+ )
+ )
+ self._assert_item_added(
+ db_map.add_entity_alternative_item(
+ entity_class_name="Object", entity_byname=("none_false",), alternative_name="second", active=False
+ )
+ )
+ self._assert_item_added(
+ db_map.add_entity_alternative_item(
+ entity_class_name="Object", entity_byname=("false_none",), alternative_name="first", active=False
+ )
+ )
+ db_map.commit_session("Add test data.")
+
+ def _assert_item_added(self, result):
+ self.assertIsNone(result[1])
+ self.assertIsNotNone(result[0])
+
+ def test_execution(self):
+ this_file = Path(__file__)
+ completed = subprocess.run((sys.executable, "-m", "spinetoolbox", "--execute-only", str(this_file.parent)))
+ self.assertEqual(completed.returncode, 0)
+ self.assertTrue(self._exporter_output_path.exists())
+ self.assertEqual(len(list(self._exporter_output_path.iterdir())), 1)
+ output_dir = next(iter(self._exporter_output_path.iterdir()))
+ filter_id = (output_dir / ".filter_id").read_text(encoding="utf-8").splitlines()
+ self.assertEqual(len(filter_id), 1)
+ self.assertEqual(filter_id, ["scenario - Source"])
+ entities = (output_dir / "entities.csv").read_text(encoding="utf-8").splitlines()
+ expected = ["Object,none_none", "Object,none_true", "Object,true_none", "Object,true_true", "Object,false_true"]
+ self.assertCountEqual(entities, expected)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/execution_tests/hello_world_on_server/.gitignore b/execution_tests/hello_world_on_server/.gitignore
new file mode 100644
index 000000000..7dd55e79e
--- /dev/null
+++ b/execution_tests/hello_world_on_server/.gitignore
@@ -0,0 +1,3 @@
+.spinetoolbox/local/
+.spinetoolbox/items/simple_tool/output/
+*.bak*
diff --git a/execution_tests/hello_world_on_server/.spinetoolbox/project.json b/execution_tests/hello_world_on_server/.spinetoolbox/project.json
new file mode 100644
index 000000000..a0ad736cd
--- /dev/null
+++ b/execution_tests/hello_world_on_server/.spinetoolbox/project.json
@@ -0,0 +1,59 @@
+{
+ "project": {
+ "version": 13,
+ "description": "",
+ "settings": {
+ "enable_execute_all": true
+ },
+ "specifications": {
+ "Tool": [
+ {
+ "type": "path",
+ "relative": true,
+ "path": ".spinetoolbox/specifications/Tool/simple_tool_spec.json"
+ }
+ ]
+ },
+ "connections": [
+ {
+ "name": "from Data Connection 1 to Simple Tool",
+ "from": [
+ "Data Connection 1",
+ "right"
+ ],
+ "to": [
+ "Simple Tool",
+ "left"
+ ]
+ }
+ ],
+ "jumps": []
+ },
+ "items": {
+ "Data Connection 1": {
+ "type": "Data Connection",
+ "description": "",
+ "x": -184.36899456147438,
+ "y": -16.491179330385762,
+ "file_references": [
+ {
+ "type": "path",
+ "relative": true,
+ "path": "input_file.txt"
+ }
+ ],
+ "db_references": []
+ },
+ "Simple Tool": {
+ "type": "Tool",
+ "description": "",
+ "x": -28.398563508064505,
+ "y": 31.54655297939068,
+ "specification": "Simple Tool Spec",
+ "execute_in_work": true,
+ "cmd_line_args": [],
+ "kill_completed_processes": false,
+ "log_process_output": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/execution_tests/hello_world_on_server/.spinetoolbox/specifications/Tool/simple_tool_spec.json b/execution_tests/hello_world_on_server/.spinetoolbox/specifications/Tool/simple_tool_spec.json
new file mode 100644
index 000000000..75f0485ab
--- /dev/null
+++ b/execution_tests/hello_world_on_server/.spinetoolbox/specifications/Tool/simple_tool_spec.json
@@ -0,0 +1,17 @@
+{
+ "name": "Simple Tool Spec",
+ "tooltype": "python",
+ "includes": [
+ "simple_script.py"
+ ],
+ "description": "",
+ "inputfiles": [
+ "input_file.txt"
+ ],
+ "inputfiles_opt": [],
+ "outputfiles": [
+ "output_file.txt"
+ ],
+ "cmdline_args": [],
+ "includes_main_path": "../../.."
+}
\ No newline at end of file
diff --git a/execution_tests/hello_world_on_server/__init__.py b/execution_tests/hello_world_on_server/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/execution_tests/hello_world_on_server/client_secfolder/private_keys/client.key_secret b/execution_tests/hello_world_on_server/client_secfolder/private_keys/client.key_secret
new file mode 100644
index 000000000..c2609edda
--- /dev/null
+++ b/execution_tests/hello_world_on_server/client_secfolder/private_keys/client.key_secret
@@ -0,0 +1,8 @@
+# **** Generated on 2021-09-16 08:25:54.752807 by pyzmq ****
+# ZeroMQ CURVE **Secret** Certificate
+# DO NOT PROVIDE THIS FILE TO OTHER USERS nor change its permissions.
+
+metadata
+curve
+ public-key = "J53)B+s.XJ7sVu3S1$o6FcYCp*= 30:\n exit(1)\nout_url = sys.argv[2]\nout_db_map = DatabaseMapping(out_url)\nimport_object_parameter_values(out_db_map, ((\"Counter\", \"loop_counter\", \"count\", counter),))\nout_db_map.commit_session(\"Increment counter.\")\nout_db_map.connection.close()\nexit(0)"
+ "script": "import sys\nfrom spinedb_api import DatabaseMapping, from_database, import_parameter_values\nin_url = sys.argv[1]\nwith DatabaseMapping(in_url) as in_db_map:\n\tsq = in_db_map.entity_parameter_value_sq\n\tday_count_row = in_db_map.query(sq).filter(sq.c.entity_class_name==\"Timeline\", sq.c.entity_name==\"days_of_our_lives\", sq.c.parameter_name==\"cumulative_count\").first()\nday_count = from_database(day_count_row.value, day_count_row.type)\ncounter = day_count.values[-1]\nif counter >= 30:\n exit(1)\nout_url = sys.argv[2]\nwith DatabaseMapping(out_url) as out_db_map:\n\timport_parameter_values(out_db_map, ((\"Counter\", \"loop_counter\", \"count\", counter),))\n\tout_db_map.commit_session(\"Increment counter.\")\nexit(0)\n"
},
"cmd_line_args": [
{
@@ -106,10 +109,7 @@
}
]
}
- ],
- "settings": {
- "enable_execute_all": true
- }
+ ]
},
"items": {
"Write data": {
@@ -124,7 +124,9 @@
"type": "resource",
"arg": "db_url@Loop counter store"
}
- ]
+ ],
+ "kill_completed_processes": false,
+ "log_process_output": false
},
"Import": {
"type": "Importer",
@@ -133,7 +135,6 @@
"y": -4.007282712511945,
"specification": "Import data",
"cancel_on_error": false,
- "purge_before_writing": false,
"on_conflict": "merge",
"file_selection": [
[
@@ -181,7 +182,6 @@
"y": -4.007282712511941,
"specification": "Counter data importer",
"cancel_on_error": false,
- "purge_before_writing": false,
"on_conflict": "merge",
"file_selection": [
[
diff --git a/execution_tests/loop_condition_with_cmd_line_args/.spinetoolbox/specifications/Tool/data_writer.json b/execution_tests/loop_condition_with_cmd_line_args/.spinetoolbox/specifications/Tool/data_writer.json
index 4892395ab..0fe2825dc 100644
--- a/execution_tests/loop_condition_with_cmd_line_args/.spinetoolbox/specifications/Tool/data_writer.json
+++ b/execution_tests/loop_condition_with_cmd_line_args/.spinetoolbox/specifications/Tool/data_writer.json
@@ -11,6 +11,5 @@
"data.csv"
],
"cmdline_args": [],
- "execute_in_work": true,
"includes_main_path": "../../.."
}
\ No newline at end of file
diff --git a/execution_tests/loop_condition_with_cmd_line_args/execution_test.py b/execution_tests/loop_condition_with_cmd_line_args/execution_test.py
index 9583e6812..d13820658 100644
--- a/execution_tests/loop_condition_with_cmd_line_args/execution_test.py
+++ b/execution_tests/loop_condition_with_cmd_line_args/execution_test.py
@@ -3,7 +3,7 @@
import subprocess
import sys
import unittest
-from spinedb_api import DatabaseMapping, from_database, Map
+from spinedb_api import create_new_spine_database, DatabaseMapping, from_database, Map
class LoopConditionWithCmdLineArgs(unittest.TestCase):
@@ -24,23 +24,20 @@ def setUp(self):
database_path.parent.mkdir(parents=True, exist_ok=True)
if database_path.exists():
database_path.unlink()
- db_map = DatabaseMapping(url, create=True)
- db_map.connection.close()
+ create_new_spine_database(url)
def test_execution(self):
completed = subprocess.run((sys.executable, "-m", "spinetoolbox", "--execute-only", str(self._root_path)))
self.assertEqual(completed.returncode, 0)
- db_map = DatabaseMapping(self._loop_counter_database_url)
- value_rows = db_map.query(db_map.object_parameter_value_sq).all()
- self.assertEqual(len(value_rows), 1)
- loop_counter = from_database(value_rows[0].value, value_rows[0].type)
- db_map.connection.close()
+ with DatabaseMapping(self._loop_counter_database_url) as db_map:
+ value_rows = db_map.query(db_map.parameter_value_sq).all()
+ self.assertEqual(len(value_rows), 1)
+ loop_counter = from_database(value_rows[0].value, value_rows[0].type)
self.assertEqual(loop_counter, 20.0)
- db_map = DatabaseMapping(self._output_database_url)
- value_rows = db_map.query(db_map.object_parameter_value_sq).all()
- self.assertEqual(len(value_rows), 1)
- output_value = from_database(value_rows[0].value, value_rows[0].type)
- db_map.connection.close()
+ with DatabaseMapping(self._output_database_url) as db_map:
+ value_rows = db_map.query(db_map.parameter_value_sq).all()
+ self.assertEqual(len(value_rows), 1)
+ output_value = from_database(value_rows[0].value, value_rows[0].type)
expected_x = [f"T{i:03}" for i in range(31)]
expected_y = [float(i) for i in range(31)]
self.assertEqual(output_value, Map(expected_x, expected_y))
diff --git a/execution_tests/loop_condition_with_cmd_line_args/tool.py b/execution_tests/loop_condition_with_cmd_line_args/tool.py
index ef6eafc39..cb70bca3a 100644
--- a/execution_tests/loop_condition_with_cmd_line_args/tool.py
+++ b/execution_tests/loop_condition_with_cmd_line_args/tool.py
@@ -3,13 +3,14 @@
from spinedb_api import DatabaseMapping, from_database
url = sys.argv[1]
-db_map = DatabaseMapping(url)
-sq = db_map.object_parameter_value_sq
-count_row = db_map.query(sq).filter(
- sq.c.object_class_name == "Counter", sq.c.object_name == "loop_counter", sq.c.parameter_name == "count"
-).first()
+with DatabaseMapping(url) as db_map:
+ sq = db_map.entity_parameter_value_sq
+ count_row = (
+ db_map.query(sq)
+ .filter(sq.c.entity_class_name == "Counter", sq.c.entity_name == "loop_counter", sq.c.parameter_name == "count")
+ .first()
+ )
count = int(from_database(count_row.value, count_row.type))
-db_map.connection.close()
data = [[f"T{i:03}", i] for i in range(count, count + 11)]
with open("data.csv", "w", newline="") as data_file:
diff --git a/execution_tests/merger_write_order/.spinetoolbox/project.json b/execution_tests/merger_write_order/.spinetoolbox/project.json
index c33bab31c..15d632063 100644
--- a/execution_tests/merger_write_order/.spinetoolbox/project.json
+++ b/execution_tests/merger_write_order/.spinetoolbox/project.json
@@ -1,6 +1,6 @@
{
"project": {
- "version": 11,
+ "version": 13,
"description": "",
"specifications": {},
"connections": [
diff --git a/execution_tests/merger_write_order/execution_test.py b/execution_tests/merger_write_order/execution_test.py
index 6fd7ef267..3ec7c2a4d 100644
--- a/execution_tests/merger_write_order/execution_test.py
+++ b/execution_tests/merger_write_order/execution_test.py
@@ -3,7 +3,7 @@
import sys
import unittest
-from spinedb_api import DatabaseMapping, from_database, import_functions
+from spinedb_api import create_new_spine_database, DatabaseMapping, from_database, import_functions
class MergerWriteOrder(unittest.TestCase):
@@ -18,36 +18,30 @@ def setUp(self):
database_path.parent.mkdir(parents=True, exist_ok=True)
if database_path.exists():
database_path.unlink()
- spoon_volumes = {
- self._source_database_1_path: 1.0,
- self._source_database_2_path: 99.0
- }
+ spoon_volumes = {self._source_database_1_path: 1.0, self._source_database_2_path: 99.0}
for database_path, spoon_volume in spoon_volumes.items():
url = "sqlite:///" + str(database_path)
- db_map = DatabaseMapping(url, create=True)
- import_functions.import_object_classes(db_map, ("Widget",))
- import_functions.import_objects(db_map, (("Widget", "spoon"),))
- import_functions.import_object_parameters(db_map, (("Widget", "volume"),))
- import_functions.import_object_parameter_values(db_map, (("Widget", "spoon", "volume", spoon_volume, "Base"),))
- db_map.commit_session("Add test data.")
- db_map.connection.close()
+ with DatabaseMapping(url, create=True) as db_map:
+ import_functions.import_entity_classes(db_map, ("Widget",))
+ import_functions.import_entities(db_map, (("Widget", "spoon"),))
+ import_functions.import_parameter_definitions(db_map, (("Widget", "volume"),))
+ import_functions.import_parameter_values(db_map, (("Widget", "spoon", "volume", spoon_volume, "Base"),))
+ db_map.commit_session("Add test data.")
self._sink_url = "sqlite:///" + str(self._sink_database_path)
- db_map = DatabaseMapping(self._sink_url, create=True)
- db_map.connection.close()
+ create_new_spine_database(self._sink_url)
def test_execution(self):
this_file = Path(__file__)
completed = subprocess.run((sys.executable, "-m", "spinetoolbox", "--execute-only", str(this_file.parent)))
self.assertEqual(completed.returncode, 0)
- db_map = DatabaseMapping(self._sink_url)
- value_rows = db_map.query(db_map.object_parameter_value_sq).all()
- self.assertEqual(len(value_rows), 1)
- self.assertEqual(value_rows[0].object_class_name, "Widget")
- self.assertEqual(value_rows[0].object_name, "spoon")
- self.assertEqual(value_rows[0].parameter_name, "volume")
- self.assertEqual(value_rows[0].alternative_name, "Base")
- self.assertEqual(from_database(value_rows[0].value, value_rows[0].type), 99.0)
- db_map.connection.close()
+ with DatabaseMapping(self._sink_url) as db_map:
+ value_rows = db_map.query(db_map.entity_parameter_value_sq).all()
+ self.assertEqual(len(value_rows), 1)
+ self.assertEqual(value_rows[0].entity_class_name, "Widget")
+ self.assertEqual(value_rows[0].entity_name, "spoon")
+ self.assertEqual(value_rows[0].parameter_name, "volume")
+ self.assertEqual(value_rows[0].alternative_name, "Base")
+ self.assertEqual(from_database(value_rows[0].value, value_rows[0].type), 99.0)
if __name__ == '__main__':
diff --git a/execution_tests/modify_connection_filter_by_script/.spinetoolbox/project.json b/execution_tests/modify_connection_filter_by_script/.spinetoolbox/project.json
index 456025258..3d541bee9 100644
--- a/execution_tests/modify_connection_filter_by_script/.spinetoolbox/project.json
+++ b/execution_tests/modify_connection_filter_by_script/.spinetoolbox/project.json
@@ -1,6 +1,6 @@
{
"project": {
- "version": 11,
+ "version": 13,
"description": "",
"specifications": {
"Exporter": [
diff --git a/execution_tests/modify_connection_filter_by_script/execution_test.py b/execution_tests/modify_connection_filter_by_script/execution_test.py
index f30f38028..b4661a268 100644
--- a/execution_tests/modify_connection_filter_by_script/execution_test.py
+++ b/execution_tests/modify_connection_filter_by_script/execution_test.py
@@ -5,14 +5,15 @@
import subprocess
import unittest
from spinedb_api import (
- DiffDatabaseMapping,
+ DatabaseMapping,
import_alternatives,
+ import_entities,
+ import_entity_alternatives,
+ import_entity_classes,
+ import_parameter_definitions,
+ import_parameter_values,
import_scenario_alternatives,
import_scenarios,
- import_object_classes,
- import_objects,
- import_object_parameters,
- import_object_parameter_values,
)
@@ -29,22 +30,22 @@ def setUp(self):
if self._database_path.exists():
self._database_path.unlink()
url = "sqlite:///" + str(self._database_path)
- db_map = DiffDatabaseMapping(url, create=True)
- import_object_classes(db_map, ("object_class",))
- import_objects(db_map, (("object_class", "object"),))
- import_object_parameters(db_map, (("object_class", "parameter"),))
- import_alternatives(db_map, ("alternative",))
- import_object_parameter_values(
- db_map,
- (
- ("object_class", "object", "parameter", 1.0, "Base"),
- ("object_class", "object", "parameter", 2.0, "alternative"),
- ),
- )
- import_scenarios(db_map, (("scenario", True),))
- import_scenario_alternatives(db_map, (("scenario", "alternative"),))
- db_map.commit_session("Add test data.")
- db_map.connection.close()
+ with DatabaseMapping(url, create=True) as db_map:
+ import_entity_classes(db_map, (("object_class",),))
+ import_entities(db_map, (("object_class", "object"),))
+ import_parameter_definitions(db_map, (("object_class", "parameter"),))
+ import_alternatives(db_map, ("alternative",))
+ import_entity_alternatives(db_map, (("object_class", "object", "alternative", True),))
+ import_parameter_values(
+ db_map,
+ (
+ ("object_class", "object", "parameter", 1.0, "Base"),
+ ("object_class", "object", "parameter", 2.0, "alternative"),
+ ),
+ )
+ import_scenarios(db_map, (("scenario", True),))
+ import_scenario_alternatives(db_map, (("scenario", "alternative"),))
+ db_map.commit_session("Add test data.")
def test_execution(self):
completed = subprocess.run(
diff --git a/execution_tests/modify_connection_filter_by_script/mod.py b/execution_tests/modify_connection_filter_by_script/mod.py
index e154eccc3..7ddf6bfc5 100644
--- a/execution_tests/modify_connection_filter_by_script/mod.py
+++ b/execution_tests/modify_connection_filter_by_script/mod.py
@@ -3,10 +3,7 @@
db_path = project.project_dir / ".spinetoolbox" / "items" / "data" / "Data.sqlite"
db_url = "sqlite:///" + str(db_path)
-db_map = DatabaseMapping(db_url)
-try:
+with DatabaseMapping(db_url) as db_map:
scenario_ids = {r.name: r.id for r in db_map.query(db_map.scenario_sq).all()}
connection = project.find_connection("Data", "Export values")
connection.set_filter_enabled("db_url@Data", SCENARIO_FILTER_TYPE, "scenario", True)
-finally:
- db_map.connection.close()
diff --git a/execution_tests/parallel_importer/.spinetoolbox/project.json b/execution_tests/parallel_importer/.spinetoolbox/project.json
index f9dd0bc3e..9c73adb1b 100644
--- a/execution_tests/parallel_importer/.spinetoolbox/project.json
+++ b/execution_tests/parallel_importer/.spinetoolbox/project.json
@@ -1,6 +1,6 @@
{
"project": {
- "version": 11,
+ "version": 13,
"description": "",
"specifications": {
"Tool": [
diff --git a/execution_tests/parallel_importer/.spinetoolbox/specifications/Tool/test_tool.json b/execution_tests/parallel_importer/.spinetoolbox/specifications/Tool/test_tool.json
index 2ad2338db..e5bb667c5 100644
--- a/execution_tests/parallel_importer/.spinetoolbox/specifications/Tool/test_tool.json
+++ b/execution_tests/parallel_importer/.spinetoolbox/specifications/Tool/test_tool.json
@@ -11,6 +11,5 @@
"out.csv"
],
"cmdline_args": [],
- "execute_in_work": true,
"includes_main_path": "../../.."
}
\ No newline at end of file
diff --git a/execution_tests/parallel_importer/execution_test.py b/execution_tests/parallel_importer/execution_test.py
index 0b2764d9a..cae49d27b 100644
--- a/execution_tests/parallel_importer/execution_test.py
+++ b/execution_tests/parallel_importer/execution_test.py
@@ -4,14 +4,15 @@
import subprocess
import unittest
from spinedb_api import (
+ create_new_spine_database,
DatabaseMapping,
from_database,
import_alternatives,
+ import_entities,
+ import_entity_classes,
+ import_parameter_definitions,
+ import_parameter_values,
import_scenario_alternatives,
- import_object_classes,
- import_object_parameters,
- import_object_parameter_values,
- import_objects,
import_scenarios,
)
@@ -29,42 +30,44 @@ def setUp(self):
if self._source_database_path.exists():
self._source_database_path.unlink()
url = "sqlite:///" + str(self._source_database_path)
- db_map = DatabaseMapping(url, create=True)
- import_alternatives(db_map, ("alternative_1", "alternative_2"))
- import_scenarios(db_map, (("scenario_1", True), ("scenario_2", True)))
- import_scenario_alternatives(db_map, (("scenario_1", "alternative_1"), ("scenario_2", "alternative_2")))
- import_object_classes(db_map, ("content",))
- import_objects(db_map, (("content", "test_data"),))
- import_object_parameters(db_map, (("content", "only_value"),))
- import_object_parameter_values(db_map, (("content", "test_data", "only_value", 11.0, "alternative_1"),))
- import_object_parameter_values(db_map, (("content", "test_data", "only_value", 22.0, "alternative_2"),))
- db_map.commit_session("Add test data.")
- db_map.connection.close()
+ with DatabaseMapping(url, create=True) as db_map:
+ import_alternatives(db_map, ("alternative_1", "alternative_2"))
+ import_scenarios(db_map, (("scenario_1", True), ("scenario_2", True)))
+ import_scenario_alternatives(db_map, (("scenario_1", "alternative_1"), ("scenario_2", "alternative_2")))
+ import_entity_classes(db_map, ("content",))
+ import_entities(db_map, (("content", "test_data"),))
+ import_parameter_definitions(db_map, (("content", "only_value"),))
+ import_parameter_values(
+ db_map,
+ (
+ ("content", "test_data", "only_value", 11.0, "alternative_1"),
+ ("content", "test_data", "only_value", 22.0, "alternative_2"),
+ ),
+ )
+ db_map.commit_session("Add test data.")
self._sink_database_path.parent.mkdir(parents=True, exist_ok=True)
if self._sink_database_path.exists():
self._sink_database_path.unlink()
self._sink_url = "sqlite:///" + str(self._sink_database_path)
- db_map = DatabaseMapping(self._sink_url, create=True)
- db_map.connection.close()
+ create_new_spine_database(self._sink_url)
def test_execution(self):
this_file = Path(__file__)
completed = subprocess.run((sys.executable, "-m", "spinetoolbox", "--execute-only", str(this_file.parent)))
self.assertEqual(completed.returncode, 0)
- db_map = DatabaseMapping(self._sink_url)
- value_rows = db_map.query(db_map.object_parameter_value_sq).all()
+ with DatabaseMapping(self._sink_url) as db_map:
+ value_rows = db_map.query(db_map.entity_parameter_value_sq).all()
self.assertEqual(len(value_rows), 2)
expected_common_data = {
- "object_class_name": "result",
- "object_name": "test_data",
+ "entity_class_name": "result",
+ "entity_name": "test_data",
"parameter_name": "final_value",
}
scenario_1_checked = False
scenario_2_checked = False
for value_row in value_rows:
- row_as_dict = value_row._asdict()
for key, expected_value in expected_common_data.items():
- self.assertEqual(row_as_dict[key], expected_value)
+ self.assertEqual(value_row[key], expected_value)
value = from_database(value_row.value, value_row.type)
if value == 11.0:
self.assertTrue(value_row.alternative_name.startswith("scenario_1__Import@"))
@@ -74,7 +77,6 @@ def test_execution(self):
scenario_2_checked = True
self.assertTrue(scenario_1_checked)
self.assertTrue(scenario_2_checked)
- db_map.connection.close()
if __name__ == '__main__':
diff --git a/execution_tests/parallel_importer/tool.py b/execution_tests/parallel_importer/tool.py
index a26bf3e03..26f8f4f89 100644
--- a/execution_tests/parallel_importer/tool.py
+++ b/execution_tests/parallel_importer/tool.py
@@ -3,12 +3,9 @@
from spinedb_api import DatabaseMapping, from_database
url = sys.argv[1]
-db_map = DatabaseMapping(url)
-try:
- value_row = db_map.query(db_map.object_parameter_value_sq).first()
- value = from_database(value_row.value, value_row.type)
-finally:
- db_map.connection.close()
+with DatabaseMapping(url) as db_map:
+ value_row = db_map.query(db_map.parameter_value_sq).first()
+value = from_database(value_row.value, value_row.type)
with open("out.csv", "w") as out_file:
out_writer = csv.writer(out_file)
out_writer.writerow([value])
diff --git a/execution_tests/parallel_importer_with_datapackage/.spinetoolbox/project.json b/execution_tests/parallel_importer_with_datapackage/.spinetoolbox/project.json
index 9c40af724..80aea08c7 100644
--- a/execution_tests/parallel_importer_with_datapackage/.spinetoolbox/project.json
+++ b/execution_tests/parallel_importer_with_datapackage/.spinetoolbox/project.json
@@ -1,6 +1,6 @@
{
"project": {
- "version": 11,
+ "version": 13,
"description": "",
"specifications": {
"Tool": [
diff --git a/execution_tests/parallel_importer_with_datapackage/.spinetoolbox/specifications/Tool/test_tool.json b/execution_tests/parallel_importer_with_datapackage/.spinetoolbox/specifications/Tool/test_tool.json
index 8c5d2dd0a..2a920cdea 100644
--- a/execution_tests/parallel_importer_with_datapackage/.spinetoolbox/specifications/Tool/test_tool.json
+++ b/execution_tests/parallel_importer_with_datapackage/.spinetoolbox/specifications/Tool/test_tool.json
@@ -11,6 +11,5 @@
"*.csv"
],
"cmdline_args": [],
- "execute_in_work": true,
"includes_main_path": "../../.."
}
\ No newline at end of file
diff --git a/execution_tests/parallel_importer_with_datapackage/execution_test.py b/execution_tests/parallel_importer_with_datapackage/execution_test.py
index d96d2bc19..56578ca09 100644
--- a/execution_tests/parallel_importer_with_datapackage/execution_test.py
+++ b/execution_tests/parallel_importer_with_datapackage/execution_test.py
@@ -4,14 +4,15 @@
import subprocess
import unittest
from spinedb_api import (
+ create_new_spine_database,
DatabaseMapping,
from_database,
import_alternatives,
+ import_entities,
+ import_entity_classes,
+ import_parameter_definitions,
+ import_parameter_values,
import_scenario_alternatives,
- import_object_classes,
- import_object_parameters,
- import_object_parameter_values,
- import_objects,
import_scenarios,
)
@@ -29,42 +30,44 @@ def setUp(self):
if self._source_database_path.exists():
self._source_database_path.unlink()
url = "sqlite:///" + str(self._source_database_path)
- db_map = DatabaseMapping(url, create=True)
- import_alternatives(db_map, ("alternative_1", "alternative_2"))
- import_scenarios(db_map, (("scenario_1", True), ("scenario_2", True)))
- import_scenario_alternatives(db_map, (("scenario_1", "alternative_1"), ("scenario_2", "alternative_2")))
- import_object_classes(db_map, ("content",))
- import_objects(db_map, (("content", "test_data"),))
- import_object_parameters(db_map, (("content", "only_value"),))
- import_object_parameter_values(db_map, (("content", "test_data", "only_value", 11.0, "alternative_1"),))
- import_object_parameter_values(db_map, (("content", "test_data", "only_value", 22.0, "alternative_2"),))
- db_map.commit_session("Add test data.")
- db_map.connection.close()
+ with DatabaseMapping(url, create=True) as db_map:
+ import_alternatives(db_map, ("alternative_1", "alternative_2"))
+ import_scenarios(db_map, (("scenario_1", True), ("scenario_2", True)))
+ import_scenario_alternatives(db_map, (("scenario_1", "alternative_1"), ("scenario_2", "alternative_2")))
+ import_entity_classes(db_map, ("content",))
+ import_entities(db_map, (("content", "test_data"),))
+ import_parameter_definitions(db_map, (("content", "only_value"),))
+ import_parameter_values(
+ db_map,
+ (
+ ("content", "test_data", "only_value", 11.0, "alternative_1"),
+ ("content", "test_data", "only_value", 22.0, "alternative_2"),
+ ),
+ )
+ db_map.commit_session("Add test data.")
self._sink_database_path.parent.mkdir(parents=True, exist_ok=True)
if self._sink_database_path.exists():
self._sink_database_path.unlink()
self._sink_url = "sqlite:///" + str(self._sink_database_path)
- db_map = DatabaseMapping(self._sink_url, create=True)
- db_map.connection.close()
+ create_new_spine_database(self._sink_url)
def test_execution(self):
this_file = Path(__file__)
completed = subprocess.run((sys.executable, "-m", "spinetoolbox", "--execute-only", str(this_file.parent)))
self.assertEqual(completed.returncode, 0)
- db_map = DatabaseMapping(self._sink_url)
- value_rows = db_map.query(db_map.object_parameter_value_sq).all()
+ with DatabaseMapping(self._sink_url) as db_map:
+ value_rows = db_map.query(db_map.entity_parameter_value_sq).all()
self.assertEqual(len(value_rows), 2)
expected_common_data = {
- "object_class_name": "result",
- "object_name": "test_data",
+ "entity_class_name": "result",
+ "entity_name": "test_data",
"parameter_name": "final_value",
}
scenario_1_checked = False
scenario_2_checked = False
for value_row in value_rows:
- row_as_dict = value_row._asdict()
for key, expected_value in expected_common_data.items():
- self.assertEqual(row_as_dict[key], expected_value)
+ self.assertEqual(value_row[key], expected_value)
value = from_database(value_row.value, value_row.type)
if value == 11.0:
self.assertTrue(value_row.alternative_name.startswith("scenario_1__Import@"))
@@ -74,7 +77,6 @@ def test_execution(self):
scenario_2_checked = True
self.assertTrue(scenario_1_checked)
self.assertTrue(scenario_2_checked)
- db_map.connection.close()
if __name__ == '__main__':
diff --git a/execution_tests/parallel_importer_with_datapackage/tool.py b/execution_tests/parallel_importer_with_datapackage/tool.py
index 2c1d7dd1c..5a8e6ee8b 100644
--- a/execution_tests/parallel_importer_with_datapackage/tool.py
+++ b/execution_tests/parallel_importer_with_datapackage/tool.py
@@ -3,12 +3,9 @@
from spinedb_api import DatabaseMapping, from_database
url = sys.argv[1]
-db_map = DatabaseMapping(url)
-try:
- value_row = db_map.query(db_map.object_parameter_value_sq).first()
+with DatabaseMapping(url) as db_map:
+ value_row = db_map.query(db_map.parameter_value_sq).first()
value = from_database(value_row.value, value_row.type)
-finally:
- db_map.connection.close()
with open("out.csv", "w", newline="") as out_file:
out_writer = csv.writer(out_file)
out_writer.writerow(["final_value"])
diff --git a/execution_tests/scenario_filters/.spinetoolbox/project.json b/execution_tests/scenario_filters/.spinetoolbox/project.json
index 3b24211ba..9c80d17a0 100644
--- a/execution_tests/scenario_filters/.spinetoolbox/project.json
+++ b/execution_tests/scenario_filters/.spinetoolbox/project.json
@@ -1,6 +1,6 @@
{
"project": {
- "version": 11,
+ "version": 13,
"description": "Test project to test scenario filtering in a Tool project item.",
"specifications": {
"Importer": [
diff --git a/execution_tests/scenario_filters/.spinetoolbox/specifications/Tool/write_values.json b/execution_tests/scenario_filters/.spinetoolbox/specifications/Tool/write_values.json
index 28061a4ba..e87266c80 100644
--- a/execution_tests/scenario_filters/.spinetoolbox/specifications/Tool/write_values.json
+++ b/execution_tests/scenario_filters/.spinetoolbox/specifications/Tool/write_values.json
@@ -11,7 +11,5 @@
"out.dat"
],
"cmdline_args": [],
- "execute_in_work": true,
- "includes_main_path": "../../..",
- "execution_settings": {}
+ "includes_main_path": "../../.."
}
\ No newline at end of file
diff --git a/execution_tests/scenario_filters/execution_test.py b/execution_tests/scenario_filters/execution_test.py
index 300b36eb8..b3859ef20 100644
--- a/execution_tests/scenario_filters/execution_test.py
+++ b/execution_tests/scenario_filters/execution_test.py
@@ -3,7 +3,7 @@
import shutil
import subprocess
import unittest
-from spinedb_api import DiffDatabaseMapping, import_alternatives, import_scenario_alternatives, import_scenarios
+from spinedb_api import DatabaseMapping, import_alternatives, import_scenario_alternatives, import_scenarios
class ScenarioFilters(unittest.TestCase):
@@ -18,12 +18,11 @@ def setUp(self):
if self._database_path.exists():
self._database_path.unlink()
url = "sqlite:///" + str(self._database_path)
- db_map = DiffDatabaseMapping(url, create=True)
- import_alternatives(db_map, ("alternative_1", "alternative_2"))
- import_scenarios(db_map, (("scenario_1", True), ("scenario_2", True)))
- import_scenario_alternatives(db_map, (("scenario_1", "alternative_1"), ("scenario_2", "alternative_2")))
- db_map.commit_session("Add test data.")
- db_map.connection.close()
+ with DatabaseMapping(url, create=True) as db_map:
+ import_alternatives(db_map, ("alternative_1", "alternative_2"))
+ import_scenarios(db_map, (("scenario_1", True), ("scenario_2", True)))
+ import_scenario_alternatives(db_map, (("scenario_1", "alternative_1"), ("scenario_2", "alternative_2")))
+ db_map.commit_session("Add test data.")
def test_execution(self):
this_file = Path(__file__)
diff --git a/execution_tests/scenario_filters/tool.py b/execution_tests/scenario_filters/tool.py
index 1104c615b..5fdc34dab 100644
--- a/execution_tests/scenario_filters/tool.py
+++ b/execution_tests/scenario_filters/tool.py
@@ -2,8 +2,7 @@
from spinedb_api import DatabaseMapping, from_database
url = sys.argv[1]
-db_map = DatabaseMapping(url)
-parameter_value = from_database(db_map.query(db_map.parameter_value_sq).first().value)
+with DatabaseMapping(url) as db_map:
+ parameter_value = from_database(db_map.query(db_map.parameter_value_sq).first().value)
with open("out.dat", "w") as out_file:
out_file.write(f"{parameter_value}")
-db_map.connection.close()
diff --git a/execution_tests/simple_importer_on_server/.gitignore b/execution_tests/simple_importer_on_server/.gitignore
new file mode 100644
index 000000000..72f8f3a48
--- /dev/null
+++ b/execution_tests/simple_importer_on_server/.gitignore
@@ -0,0 +1,2 @@
+.spinetoolbox/local/
+*.bak*
diff --git a/execution_tests/simple_importer_on_server/.spinetoolbox/items/raw_data/units.xlsx b/execution_tests/simple_importer_on_server/.spinetoolbox/items/raw_data/units.xlsx
new file mode 100644
index 000000000..10f8a3925
Binary files /dev/null and b/execution_tests/simple_importer_on_server/.spinetoolbox/items/raw_data/units.xlsx differ
diff --git a/execution_tests/simple_importer_on_server/.spinetoolbox/project.json b/execution_tests/simple_importer_on_server/.spinetoolbox/project.json
new file mode 100644
index 000000000..e87dfd0d0
--- /dev/null
+++ b/execution_tests/simple_importer_on_server/.spinetoolbox/project.json
@@ -0,0 +1,104 @@
+{
+ "project": {
+ "version": 13,
+ "description": "",
+ "settings": {
+ "enable_execute_all": true
+ },
+ "specifications": {
+ "Importer": [
+ {
+ "type": "path",
+ "relative": true,
+ "path": "Importer 1 - units.xlsx.json"
+ }
+ ]
+ },
+ "connections": [
+ {
+ "name": "from Raw data to Importer 1",
+ "from": [
+ "Raw data",
+ "right"
+ ],
+ "to": [
+ "Importer 1",
+ "left"
+ ]
+ },
+ {
+ "name": "from Importer 1 to DS1",
+ "from": [
+ "Importer 1",
+ "right"
+ ],
+ "to": [
+ "DS1",
+ "left"
+ ],
+ "options": {
+ "purge_before_writing": true,
+ "purge_settings": {
+ "alternative": true,
+ "scenario": true,
+ "scenario_alternative": true,
+ "entity_class": true,
+ "entity": true,
+ "entity_group": true,
+ "entity_alternative": true,
+ "parameter_value_list": true,
+ "list_value": true,
+ "parameter_definition": true,
+ "parameter_value": true,
+ "metadata": true,
+ "entity_metadata": true,
+ "parameter_value_metadata": true
+ },
+ "write_index": 1
+ }
+ }
+ ],
+ "jumps": []
+ },
+ "items": {
+ "Importer 1": {
+ "type": "Importer",
+ "description": "",
+ "x": 76.49024590313154,
+ "y": -79.19504574424269,
+ "specification": "Importer 1 - units.xlsx",
+ "cancel_on_error": true,
+ "on_conflict": "replace",
+ "file_selection": [
+ [
+ "/units.xlsx",
+ true
+ ]
+ ]
+ },
+ "DS1": {
+ "type": "Data Store",
+ "description": "",
+ "x": 224.4333033622387,
+ "y": -154.05366960162178,
+ "url": {
+ "dialect": "sqlite",
+ "host": "",
+ "port": "",
+ "database": {
+ "type": "path",
+ "relative": true,
+ "path": ".spinetoolbox/items/ds1/DS1.sqlite"
+ }
+ }
+ },
+ "Raw data": {
+ "type": "Data Connection",
+ "description": "",
+ "x": -83.2938157444066,
+ "y": -151.04282579168796,
+ "file_references": [],
+ "db_references": []
+ }
+ }
+}
\ No newline at end of file
diff --git a/execution_tests/simple_importer_on_server/Importer 1 - units.xlsx.json b/execution_tests/simple_importer_on_server/Importer 1 - units.xlsx.json
new file mode 100644
index 000000000..94ec5e3dd
--- /dev/null
+++ b/execution_tests/simple_importer_on_server/Importer 1 - units.xlsx.json
@@ -0,0 +1,44 @@
+{
+ "name": "Importer 1 - units.xlsx",
+ "item_type": "Importer",
+ "mapping": {
+ "table_mappings": {
+ "Sheet1": [
+ {
+ "": {
+ "mapping": [
+ {
+ "map_type": "EntityClass",
+ "position": 0
+ },
+ {
+ "map_type": "Entity",
+ "position": 1
+ },
+ {
+ "map_type": "EntityMetadata",
+ "position": "hidden"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "selected_tables": [
+ "Sheet1"
+ ],
+ "table_options": {
+ "Sheet1": {}
+ },
+ "table_types": {
+ "Sheet1": {
+ "0": "string",
+ "1": "string"
+ }
+ },
+ "table_default_column_type": {},
+ "table_row_types": {},
+ "source_type": "ExcelConnector"
+ },
+ "description": ""
+}
\ No newline at end of file
diff --git a/execution_tests/simple_importer_on_server/__init__.py b/execution_tests/simple_importer_on_server/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/execution_tests/simple_importer_on_server/execution_test.py b/execution_tests/simple_importer_on_server/execution_test.py
new file mode 100644
index 000000000..12c89bf70
--- /dev/null
+++ b/execution_tests/simple_importer_on_server/execution_test.py
@@ -0,0 +1,81 @@
+import os
+import sys
+from pathlib import Path
+import subprocess
+import unittest
+import zmq
+from spinetoolbox.config import PROJECT_ZIP_FILENAME
+from spine_engine.server.engine_server import EngineServer, ServerSecurityModel
+from spinedb_api import create_new_spine_database, DatabaseMapping
+
+
+class RunSimpleImporterOnServer(unittest.TestCase):
+ _root_path = Path(__file__).parent
+ _db_path = _root_path / ".spinetoolbox" / "items" / "ds1" / "DS1.sqlite"
+ _zip_fname = PROJECT_ZIP_FILENAME + ".zip"
+ _zip_fpath = _root_path.parent / _zip_fname
+
+ def setUp(self):
+ self.service = EngineServer("tcp", 50003, ServerSecurityModel.NONE, "")
+ self.context = zmq.Context()
+ self.socket = self.context.socket(zmq.DEALER)
+ self.socket.identity = "Worker1".encode("ascii")
+ self.socket.connect("tcp://localhost:50003")
+ self.pull_socket = self.context.socket(zmq.PULL)
+ self.poller = zmq.Poller()
+ self.poller.register(self.socket, zmq.POLLIN)
+ self.poller.register(self.pull_socket, zmq.POLLIN)
+ self.make_db_for_ds1(self._db_path)
+
+ def tearDown(self):
+ self.service.close()
+ if not self.socket.closed:
+ self.socket.close()
+ if not self.pull_socket.closed:
+ self.pull_socket.close()
+ if not self.context.closed:
+ self.context.term()
+ if self._zip_fpath.exists():
+ os.remove(self._zip_fpath)
+
+ def make_db_for_ds1(self, p):
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
+ if self._db_path.exists():
+ self._db_path.unlink()
+ self._db_url = "sqlite:///" + str(self._db_path)
+ create_new_spine_database(self._db_url)
+
+ def test_execution(self):
+ # Check that DS1.sqlite is empty
+ with DatabaseMapping(self._db_url) as db_map:
+ entities = db_map.get_items("entity")
+ self.assertEqual(0, len(entities))
+ completed = subprocess.run(
+ (
+ sys.executable,
+ "-m",
+ "spinetoolbox",
+ "--execute-only",
+ "--execute-remotely",
+ "server.cfg",
+ str(self._root_path),
+ )
+ )
+ self.assertEqual(completed.returncode, 0)
+ # Check that entities are now in DB
+ with DatabaseMapping(self._db_url) as db_map:
+ entities = db_map.get_items("entity")
+ self.assertEqual(3, len(entities))
+ for entity in entities:
+ if entity["id"].db_id == 1:
+ self.assertEqual("Factory1", entity["name"])
+ elif entity["id"].db_id == 2:
+ self.assertEqual("Factory2", entity["name"])
+ elif entity["id"].db_id == 3:
+ self.assertEqual("Factory3", entity["name"])
+ else:
+ self.fail()
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/execution_tests/simple_importer_on_server/server.cfg b/execution_tests/simple_importer_on_server/server.cfg
new file mode 100644
index 000000000..0b93b193f
--- /dev/null
+++ b/execution_tests/simple_importer_on_server/server.cfg
@@ -0,0 +1,4 @@
+127.0.0.1
+50003
+off
+./some/path/that/does/not/matter
diff --git a/fig/add_create_dp_tool.png b/fig/add_create_dp_tool.png
deleted file mode 100644
index 514569df9..000000000
Binary files a/fig/add_create_dp_tool.png and /dev/null differ
diff --git a/fig/add_run_hydro_tool.png b/fig/add_run_hydro_tool.png
deleted file mode 100644
index 748ce2809..000000000
Binary files a/fig/add_run_hydro_tool.png and /dev/null differ
diff --git a/fig/add_tool_template.png b/fig/add_tool_template.png
deleted file mode 100644
index 3fc471598..000000000
Binary files a/fig/add_tool_template.png and /dev/null differ
diff --git a/fig/connections1.png b/fig/connections1.png
deleted file mode 100644
index 012e1ceb1..000000000
Binary files a/fig/connections1.png and /dev/null differ
diff --git a/fig/connections2.png b/fig/connections2.png
deleted file mode 100644
index c6c30747f..000000000
Binary files a/fig/connections2.png and /dev/null differ
diff --git a/fig/datapackage.png b/fig/datapackage.png
deleted file mode 100644
index d5ce085a0..000000000
Binary files a/fig/datapackage.png and /dev/null differ
diff --git a/fig/new_swedish_hydro.png b/fig/new_swedish_hydro.png
deleted file mode 100644
index 3556b7295..000000000
Binary files a/fig/new_swedish_hydro.png and /dev/null differ
diff --git a/fig/rawCSV.png b/fig/rawCSV.png
deleted file mode 100644
index 5455264e6..000000000
Binary files a/fig/rawCSV.png and /dev/null differ
diff --git a/fig/tool_templates.png b/fig/tool_templates.png
deleted file mode 100644
index 046c6441b..000000000
Binary files a/fig/tool_templates.png and /dev/null differ
diff --git a/pyproject.toml b/pyproject.toml
index b59ffa1f0..cdf038686 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,7 +13,7 @@ classifiers = [
]
requires-python = ">=3.8.1, <3.12"
dependencies = [
- "pyside6 >= 6.5.0, != 6.5.3, < 6.6",
+ "pyside6 >= 6.5.0, != 6.5.3, != 6.6.3",
"jupyter-client >=6.0",
"qtconsole >=5.1",
"sqlalchemy >=1.3",
@@ -34,9 +34,6 @@ dependencies = [
Documentation = "https://spine-toolbox.readthedocs.io/"
Repository = "https://github.com/spine-tools/Spine-Toolbox"
-[project.optional-dependencies]
-dev = ["coverage[toml]"]
-
[project.scripts]
spinetoolbox = "spinetoolbox.main:main"
spine-db-editor = "spinetoolbox.spine_db_editor.main:main"
@@ -47,10 +44,8 @@ build-backend = "setuptools.build_meta"
[tool.setuptools_scm]
write_to = "spinetoolbox/_version.py"
-# Temporarily set the version scheme to guess-next-dev for 0.7 because we have
-# a separate 0.8-dev branch for 0.8.
-# Normally we use release-branch-semver here.
-version_scheme = "guess-next-dev"
+# default: guess-next-dev, alternatives: post-release, no-guess-dev
+version_scheme = "release-branch-semver"
[tool.setuptools]
zip-safe = false
@@ -73,7 +68,7 @@ branch = true
[tool.coverage.report]
ignore_errors = true
+
[tool.black]
line-length = 120
-skip-string-normalization = true
exclude = '\.git|ui|resources_icons_rc.py|resources_logos_rc.py'
diff --git a/requirements.txt b/requirements.txt
index 0e40f2ce9..0ff41d492 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
-e git+https://github.com/spine-tools/Spine-Database-API.git#egg=spinedb_api
-e git+https://github.com/spine-tools/spine-engine.git#egg=spine_engine
-e git+https://github.com/spine-tools/spine-items.git#egg=spine_items
--e .[dev]
+-e .
diff --git a/spinetoolbox.py b/spinetoolbox.py
index 7acac0f83..eaa17cfe5 100644
--- a/spinetoolbox.py
+++ b/spinetoolbox.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
diff --git a/spinetoolbox.spec b/spinetoolbox.spec
new file mode 100644
index 000000000..1f7bcb990
--- /dev/null
+++ b/spinetoolbox.spec
@@ -0,0 +1,85 @@
+######################################################################################################################
+# 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 is the PyInstaller spec file for Spine Toolbox.
+
+We bundle an embeddable Python interpreter with Toolbox
+so all basic functionality should be available without the need to install Python.
+
+Steps to bundle Spine Toolbox:
+
+1. Activate Toolbox Python environment.
+2. Install PyInstaller using Pip.
+3. Download one of the 64-bit *embeddable* Python packages from https://www.python.org/downloads/windows/
+4. Unzip the downloaded package somewhere.
+5. Run
+
+python -m PyInstaller spinetoolbox.spec -- --embedded-python=
+"""
+
+import argparse
+from pathlib import Path
+import spinedb_api
+import spine_engine
+from spine_engine.config import BUNDLE_DIR
+import spine_items
+
+parser = argparse.ArgumentParser()
+parser.add_argument("--embedded-python", help="path to embedded Python interpreter", required=True)
+options = parser.parse_args()
+embedded_python_path = Path(options.embedded_python)
+spinedb_api_path = Path(spinedb_api.__file__).parent.parent
+spine_engine_path = Path(spine_engine.__file__).parent.parent
+spine_items_path = Path(spine_items.__file__).parent.parent
+data_file_target = Path()
+data_files = ("CHANGELOG.md", "README.md", "COPYING", "COPYING.LESSER")
+embedded_python_datas = [(str(path), BUNDLE_DIR) for path in embedded_python_path.iterdir()]
+a = Analysis(
+ ['spinetoolbox.py'],
+ pathex=list(set(map(str, (spinedb_api_path, spine_engine_path, spine_items_path)))),
+ binaries=[],
+ datas=[(data_file, str(Path())) for data_file in data_files] + embedded_python_datas,
+ hiddenimports=[],
+ hookspath=["PyInstaller hooks"],
+ hooksconfig={},
+ runtime_hooks=[],
+ excludes=[],
+ noarchive=False,
+)
+pyz = PYZ(a.pure)
+exe = EXE(
+ pyz,
+ a.scripts,
+ [],
+ exclude_binaries=True,
+ name='spinetoolbox',
+ icon=Path("spinetoolbox", "ui", "resources", "app.ico"),
+ debug=False,
+ bootloader_ignore_signals=False,
+ strip=False,
+ upx=True,
+ console=False,
+ disable_windowed_traceback=False,
+ argv_emulation=False,
+ target_arch=None,
+ codesign_identity=None,
+ entitlements_file=None,
+)
+coll = COLLECT(
+ exe,
+ a.binaries,
+ a.datas,
+ strip=False,
+ upx=True,
+ upx_exclude=[],
+ name='Spine Toolbox',
+)
diff --git a/spinetoolbox/__init__.py b/spinetoolbox/__init__.py
index ab8b72adc..dc2fd686b 100644
--- a/spinetoolbox/__init__.py
+++ b/spinetoolbox/__init__.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,8 +10,6 @@
# this program. If not, see .
######################################################################################################################
-"""
-spinetoolbox package.
-"""
+"""spinetoolbox package."""
from .version import __version__, __version_info__
diff --git a/spinetoolbox/__main__.py b/spinetoolbox/__main__.py
index baf36aa05..1d259d4bd 100644
--- a/spinetoolbox/__main__.py
+++ b/spinetoolbox/__main__.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Spine Toolbox application main file.
-"""
-
+"""Spine Toolbox application main file."""
import sys
from spinetoolbox.main import main
diff --git a/spinetoolbox/config.py b/spinetoolbox/config.py
index b55e95ee1..4995d4580 100644
--- a/spinetoolbox/config.py
+++ b/spinetoolbox/config.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,16 +10,12 @@
# this program. If not, see .
######################################################################################################################
-"""
-Application constants and style sheets
-"""
-
+"""Application constants and style sheets."""
import sys
import os
from pathlib import Path
-# NOTE: All required Python package versions are in setup.cfg
-LATEST_PROJECT_VERSION = 11
+LATEST_PROJECT_VERSION = 13
# For the Add/Update SpineOpt wizard
REQUIRED_SPINE_OPT_VERSION = "0.6.9"
@@ -26,9 +23,9 @@
# Invalid characters for directory names
# NOTE: "." is actually valid in a directory name but this is
# to prevent the user from creating directories like /..../
-INVALID_CHARS = ["<", ">", ":", "\"", "/", "\\", "|", "?", "*", "."]
+INVALID_CHARS = ["<", ">", ":", '"', "/", "\\", "|", "?", "*", "."]
# Invalid characters for file names
-INVALID_FILENAME_CHARS = ["<", ">", ":", "\"", "/", "\\", "|", "?", "*"]
+INVALID_FILENAME_CHARS = ["<", ">", ":", '"', "/", "\\", "|", "?", "*"]
# Paths to application, configuration file, default project and work dirs, and documentation index page
_frozen = getattr(sys, "frozen", False)
@@ -66,10 +63,12 @@
"QLabel{color: black;}"
"QGroupBox{border: 2px solid gray; "
"background-color: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0, stop: 0 #80B0FF, stop: 1 #e6efff);"
+ "font-size: 14px;"
"border-radius: 5px;"
"margin-top: 0.5em;}"
"QGroupBox:title{border-radius: 2px; "
- "background-color: ghostwhite;"
+ "background-color: white;"
+ "border-radius: 5px;"
"subcontrol-origin: margin;"
"subcontrol-position: top center;"
"padding-top: 0px;"
diff --git a/spinetoolbox/execution_managers.py b/spinetoolbox/execution_managers.py
index f8c7f737c..038466a69 100644
--- a/spinetoolbox/execution_managers.py
+++ b/spinetoolbox/execution_managers.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Classes to manage tool instance execution in various forms.
-"""
-
+"""Classes to manage tool instance execution in various forms."""
import logging
from PySide6.QtCore import QObject, QProcess, Slot, Signal
diff --git a/spinetoolbox/fetch_parent.py b/spinetoolbox/fetch_parent.py
index e7641f107..912758ca7 100644
--- a/spinetoolbox/fetch_parent.py
+++ b/spinetoolbox/fetch_parent.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,70 +10,69 @@
# this program. If not, see .
######################################################################################################################
-"""
-The FetchParent and FlexibleFetchParent classes.
-"""
-
-from PySide6.QtCore import QTimer, Signal, QObject
+"""The FetchParent and FlexibleFetchParent classes."""
+from PySide6.QtCore import QTimer, Signal, QObject, Qt
from .helpers import busy_effect
class FetchParent(QObject):
- """
- Attrs:
- fetch_token (int or None)
- will_have_children (bool or None): Whether this parent will have children if fetched.
- None means we don't know yet. Set to a boolean value whenever we find out.
- """
_changes_pending = Signal()
- def __init__(self, owner=None, chunk_size=1000):
+ def __init__(self, index=None, owner=None, chunk_size=1000):
"""
Args:
- owner (object): somebody who owns this FetchParent. If it's a QObject instance, then this FetchParent
- becomes obsolete whenever the owner is destroyed
- chunk_size (int or None): the number of items this parent should be happy with fetching at a time.
- If None, then no limit is imposed and the parent should fetch the entire contents of the DB.
+ index (FetchIndex, optional): an index to speedup looking up fetched items
+ owner (object, optional): somebody who owns this FetchParent.
+ If it's a QObject instance, then this FetchParent becomes obsolete whenever the owner is destroyed
+ chunk_size (int, optional): the number of items this parent should be happy with fetching at a time.
+ If None, then no limit is imposed and the parent should fetch the entire contents of the DB.
"""
super().__init__()
- self._timer = QTimer()
- self._items_to_add = {}
- self._items_to_update = {}
- self._items_to_remove = {}
+ self._version = 0
+ self._restore_item_callbacks = {}
+ self._update_item_callbacks = {}
+ self._remove_item_callbacks = {}
+ self._changes_by_db_map = {}
self._obsolete = False
self._fetched = False
self._busy = False
self._position = {}
- self.fetch_token = None
- self.will_have_children = None
+ self._timer = QTimer()
self._timer.setSingleShot(True)
self._timer.setInterval(0)
self._timer.timeout.connect(self._apply_pending_changes)
self._changes_pending.connect(self._timer.start)
+ self._index = index
self._owner = owner
if isinstance(self._owner, QObject):
self._owner.destroyed.connect(lambda obj=None: self.set_obsolete(True))
+ self.setParent(self._owner)
self.chunk_size = chunk_size
- def reset_fetching(self, fetch_token):
- """Resets fetch parent as if nothing was ever fetched.
+ def apply_changes_immediately(self):
+ # For tests
+ self._changes_pending.connect(self._apply_pending_changes, Qt.UniqueConnection)
- Args:
- fetch_token (object): current fetch token
- """
+ @property
+ def index(self):
+ return self._index
+
+ def reset(self):
+ """Resets fetch parent as if nothing was ever fetched."""
if self.is_obsolete:
return
+ self._version += 1
+ self._restore_item_callbacks.clear()
+ self._update_item_callbacks.clear()
+ self._remove_item_callbacks.clear()
self._timer.stop()
- self._items_to_add.clear()
- self._items_to_update.clear()
- self._items_to_remove.clear()
+ self._changes_by_db_map.clear()
self._fetched = False
self._busy = False
self._position.clear()
- self.fetch_token = fetch_token
- self.will_have_children = None
- self.will_have_children_change()
+ if self.index is not None:
+ self.index.reset()
def position(self, db_map):
return self._position.setdefault(db_map, 0)
@@ -84,38 +84,83 @@ def increment_position(self, db_map):
def _apply_pending_changes(self):
if self.is_obsolete:
return
- for db_map in list(self._items_to_add):
- data = self._items_to_add.pop(db_map)
- self.handle_items_added({db_map: data})
- for db_map in list(self._items_to_update):
- data = self._items_to_update.pop(db_map)
- self.handle_items_updated({db_map: data})
- for db_map in list(self._items_to_remove):
- data = self._items_to_remove.pop(db_map)
- self.handle_items_removed({db_map: data})
+ for db_map in list(self._changes_by_db_map):
+ changes = self._changes_by_db_map.pop(db_map)
+ last_handler = None
+ items = []
+ for handler, item in changes:
+ if handler == last_handler:
+ items.append(item)
+ continue
+ if items:
+ last_handler({db_map: items}) # pylint: disable=not-callable
+ items = [item]
+ last_handler = handler
+ last_handler({db_map: items})
QTimer.singleShot(0, lambda: self.set_busy(False))
- def add_item(self, item, db_map):
- self._items_to_add.setdefault(db_map, []).append(item)
+ def bind_item(self, item, db_map):
+ # NOTE: If `item` is in the process of calling callbacks in another thread,
+ # the ones added below won't be called.
+ # So, it is important to call this function before self.add_item()
+ item.add_restore_callback(self._make_restore_item_callback(db_map))
+ item.add_update_callback(self._make_update_item_callback(db_map))
+ item.add_remove_callback(self._make_remove_item_callback(db_map))
+
+ def _make_restore_item_callback(self, db_map):
+ if db_map not in self._restore_item_callbacks:
+ self._restore_item_callbacks[db_map] = _ItemCallback(self.add_item, db_map, self._version)
+ return self._restore_item_callbacks[db_map]
+
+ def _make_update_item_callback(self, db_map):
+ if db_map not in self._update_item_callbacks:
+ self._update_item_callbacks[db_map] = _ItemCallback(self.update_item, db_map, self._version)
+ return self._update_item_callbacks[db_map]
+
+ def _make_remove_item_callback(self, db_map):
+ if db_map not in self._remove_item_callbacks:
+ self._remove_item_callbacks[db_map] = _ItemCallback(self.remove_item, db_map, self._version)
+ return self._remove_item_callbacks[db_map]
+
+ def _is_valid(self, version):
+ return (version is None or version == self._version) and not self.is_obsolete
+
+ def _change_item(self, handler, item, db_map, version):
+ if not self._is_valid(version):
+ return False
+ self._changes_by_db_map.setdefault(db_map, []).append((handler, item))
self._changes_pending.emit()
+ return True
- def update_item(self, item, db_map):
- self._items_to_update.setdefault(db_map, []).append(item)
- self._changes_pending.emit()
+ def add_item(self, item, db_map, version=None):
+ return self._change_item(self.handle_items_added, item, db_map, version)
- def remove_item(self, item, db_map):
- self._items_to_remove.setdefault(db_map, []).append(item)
- self._changes_pending.emit()
+ def update_item(self, item, db_map, version=None):
+ return self._change_item(self.handle_items_updated, item, db_map, version)
+
+ def remove_item(self, item, db_map, version=None):
+ return self._change_item(self.handle_items_removed, item, db_map, version)
@property
def fetch_item_type(self):
- """Returns the type of item to fetch, e.g., "object_class".
+ """Returns the DB item type to fetch, e.g., "entity_class".
Returns:
str
"""
raise NotImplementedError()
+ def key_for_index(self, db_map):
+ """Returns the key for this parent in the index.
+
+ Args:
+ db_map (DatabaseMapping)
+
+ Returns:
+ Any
+ """
+ return None
+
# pylint: disable=no-self-use
def accepts_item(self, item, db_map):
"""Called by the associated SpineDBWorker whenever items are fetched and also added/updated/removed.
@@ -126,7 +171,7 @@ def accepts_item(self, item, db_map):
Args:
item (dict): The item
- db_map (DiffDatabaseMapping)
+ db_map (DatabaseMapping)
Returns:
bool
@@ -140,16 +185,13 @@ def shows_item(self, item, db_map):
Args:
item (dict): The item
- db_map (DiffDatabaseMapping)
+ db_map (DatabaseMapping)
Returns:
bool
"""
return True
- def will_have_children_change(self):
- """Called when the will_have_children property changes."""
-
@property
def is_obsolete(self):
return self._obsolete
@@ -160,6 +202,8 @@ def set_obsolete(self, obsolete):
Args:
obsolete (bool): whether parent has become obsolete
"""
+ if obsolete:
+ self.set_busy(False)
self._obsolete = obsolete
@property
@@ -172,6 +216,8 @@ def set_fetched(self, fetched):
Args:
fetched (bool): whether parent has been fetched completely
"""
+ if fetched:
+ self.set_busy(False)
self._fetched = fetched
@property
@@ -191,7 +237,7 @@ def handle_items_added(self, db_map_data):
Called by SpineDBWorker when items are added to the DB.
Args:
- db_map_data (dict): Mapping DiffDatabaseMapping instances to list of dict-items for which
+ db_map_data (dict): Mapping DatabaseMapping instances to list of dict-items for which
``accepts_item()`` returns True.
"""
raise NotImplementedError(self.fetch_item_type)
@@ -201,7 +247,7 @@ def handle_items_removed(self, db_map_data):
Called by SpineDBWorker when items are removed from the DB.
Args:
- db_map_data (dict): Mapping DiffDatabaseMapping instances to list of dict-items for which
+ db_map_data (dict): Mapping DatabaseMapping instances to list of dict-items for which
``accepts_item()`` returns True.
"""
raise NotImplementedError(self.fetch_item_type)
@@ -211,15 +257,15 @@ def handle_items_updated(self, db_map_data):
Called by SpineDBWorker when items are updated in the DB.
Args:
- db_map_data (dict): Mapping DiffDatabaseMapping instances to list of dict-items for which
+ db_map_data (dict): Mapping DatabaseMapping instances to list of dict-items for which
``accepts_item()`` returns True.
"""
raise NotImplementedError(self.fetch_item_type)
class ItemTypeFetchParent(FetchParent):
- def __init__(self, fetch_item_type, owner=None, chunk_size=1000):
- super().__init__(owner=owner, chunk_size=chunk_size)
+ def __init__(self, fetch_item_type, index=None, owner=None, chunk_size=1000):
+ super().__init__(index=index, owner=owner, chunk_size=chunk_size)
self._fetch_item_type = fetch_item_type
@property
@@ -252,17 +298,23 @@ def __init__(
handle_items_updated=None,
accepts_item=None,
shows_item=None,
- will_have_children_change=None,
+ key_for_index=None,
+ index=None,
owner=None,
chunk_size=1000,
):
- super().__init__(fetch_item_type, owner=owner, chunk_size=chunk_size)
- self._accepts_item = accepts_item
- self._shows_item = shows_item
+ super().__init__(fetch_item_type, index=index, owner=owner, chunk_size=chunk_size)
self._handle_items_added = handle_items_added
self._handle_items_removed = handle_items_removed
self._handle_items_updated = handle_items_updated
- self._will_have_children_change = will_have_children_change
+ self._accepts_item = accepts_item
+ self._shows_item = shows_item
+ self._key_for_index = key_for_index
+
+ def key_for_index(self, db_map):
+ if self._key_for_index is None:
+ return None
+ return self._key_for_index(db_map)
def handle_items_added(self, db_map_data):
if self._handle_items_added is None:
@@ -289,8 +341,36 @@ def shows_item(self, item, db_map):
return super().shows_item(item, db_map)
return self._shows_item(item, db_map)
- def will_have_children_change(self):
- if self._will_have_children_change is None:
- super().will_have_children_change()
- else:
- self._will_have_children_change()
+
+class FetchIndex(dict):
+ def __init__(self):
+ super().__init__()
+ self._position = {}
+
+ def reset(self):
+ self._position.clear()
+ self.clear()
+
+ def process_item(self, item, db_map):
+ raise NotImplementedError()
+
+ def position(self, db_map):
+ return self._position.setdefault(db_map, 0)
+
+ def increment_position(self, db_map):
+ self._position[db_map] += 1
+
+ def get_items(self, key, db_map):
+ return self.get(db_map, {}).get(key, [])
+
+
+class _ItemCallback:
+ def __init__(self, fn, *args):
+ self._fn = fn
+ self._args = args
+
+ def __call__(self, item):
+ return self._fn(item, *self._args)
+
+ def __str__(self):
+ return str(self._fn) + " with " + str(self._args)
diff --git a/spinetoolbox/headless.py b/spinetoolbox/headless.py
index 783bfbb7e..1147b6375 100644
--- a/spinetoolbox/headless.py
+++ b/spinetoolbox/headless.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,9 +10,8 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains facilities to open and execute projects without GUI.
-"""
+"""Contains facilities to open and execute projects without GUI."""
+import os
from copy import deepcopy
from enum import IntEnum, unique
import json
@@ -19,13 +19,15 @@
import sys
from PySide6.QtCore import QCoreApplication, QEvent, QObject, QSettings, Signal, Slot
import networkx as nx
-
from spine_engine import SpineEngineState
from spine_engine.exception import EngineInitFailed
from spine_engine.load_project_items import load_item_specification_factories
from spine_engine.utils.serialization import deserialize_path
+from spine_engine.utils.helpers import get_file_size, ExecutionDirection
+from spine_engine.server.util.zip_handler import ZipHandler
+from .server.engine_client import EngineClient, RemoteEngineInitFailed, ClientSecurityModel
from .project_item.logging_connection import HeadlessConnection
-from .config import LATEST_PROJECT_VERSION
+from .config import LATEST_PROJECT_VERSION, PROJECT_ZIP_FILENAME
from .helpers import (
make_settings_dict_for_engine,
plugins_dirs,
@@ -193,6 +195,7 @@ def __init__(self, args, startup_event_type, parent):
self._plugin_specifications = None
self._connection_dicts = None
self._jump_dicts = None
+ self._server_config = None
def _dags(self):
graph = nx.DiGraph()
@@ -210,6 +213,12 @@ def _execute(self):
return
try:
status = self._open_project()
+ if self._args.execute_remotely:
+ self._server_config = self._read_server_config()
+ if not self._server_config:
+ self._logger.msg_error.emit("Reading server config file failed.")
+ QCoreApplication.instance().exit(Status.ARGUMENT_ERROR)
+ return
if status != Status.OK:
QCoreApplication.instance().exit(status)
return
@@ -319,9 +328,16 @@ def _execute_project(self):
spec_dict["definition_file_path"] = spec.definition_file_path
self._specification_dicts.setdefault(item_type, []).append(spec_dict)
dags = self._dags()
+ job_id = self._prepare_remote_execution()
+ if not job_id:
+ self._logger.msg_error.emit("Pinging the server or uploading the project failed.")
+ return Status.ERROR
settings = make_settings_dict_for_engine(self._app_settings)
- # Force local execution in headless mode
- if not settings.get("engineSettings/remoteExecutionEnabled", "false") == "false":
+ # Enable remote execution if server config file was given, else force local execution
+ if self._server_config is not None:
+ settings["engineSettings/remoteExecutionEnabled"] = "true"
+ settings = self._insert_remote_engine_settings(settings)
+ else:
settings["engineSettings/remoteExecutionEnabled"] = "false"
selected = {name for name_list in self._args.select for name in name_list} if self._args.select else None
deselected = {name for name_list in self._args.deselect for name in name_list} if self._args.deselect else None
@@ -352,11 +368,10 @@ def _execute_project(self):
"execution_permits": execution_permits,
"items_module_name": "spine_items",
"settings": settings,
- "project_dir": self._project_dir,
+ "project_dir": solve_project_dir(self._project_dir),
}
- # exec_remotely is forced to False (see above)
- exec_remotely = settings.get("engineSettings/remoteExecutionEnabled", "false") == "true"
- engine_manager = make_engine_manager(exec_remotely)
+ exec_remotely = True if self._server_config else False
+ engine_manager = make_engine_manager(exec_remotely, job_id=job_id)
try:
engine_manager.run_engine(engine_data)
except EngineInitFailed as error:
@@ -390,6 +405,7 @@ def _process_engine_event(self, event_type, data):
"standard_execution_msg": self._handle_standard_execution_msg,
"persistent_execution_msg": self._handle_persistent_execution_msg,
"kernel_execution_msg": self._handle_kernel_execution_msg,
+ "server_status_msg": self._handle_server_status_msg,
}.get(event_type)
if handler is None:
return
@@ -408,7 +424,7 @@ def _handle_node_execution_started(self, data):
Args:
data (dict): execution start data
"""
- if data["direction"] == "BACKWARD":
+ if data["direction"] == ExecutionDirection.BACKWARD:
# Currently there are no interesting messages when executing backwards.
return
self._node_messages[data["item_name"]] = dict()
@@ -482,6 +498,103 @@ def _handle_kernel_execution_msg(self, data):
data (dict): message data
"""
+ def _handle_server_status_msg(self, data):
+ """Handles received remote execution messages."""
+ if data["msg_type"] == "success":
+ self._logger.msg_success.emit(data["text"])
+ elif data["msg_type"] == "neutral":
+ self._logger.msg.emit(data["text"])
+ elif data["msg_type"] == "fail":
+ self._logger.msg_error.emit(data["text"])
+ elif data["msg_type"] == "warning":
+ self._logger.msg_warning.emit(data["text"])
+
+ def _read_server_config(self):
+ """Reads the user provided server settings file that the client requires to establish connection.
+
+ Returns:
+ dict: Dictionary containing the EngineClient settings or None if the given config file does not exist.
+ """
+ cfg_file = self._args.execute_remotely[0]
+ cfg_fp = os.path.join(self._project_dir, cfg_file)
+ if os.path.isfile(cfg_fp):
+ with open(cfg_fp, encoding="utf-8") as fp:
+ lines = fp.readlines()
+ lines = [l.strip() for l in lines]
+ host = lines[0]
+ port = lines[1]
+ smodel = lines[2]
+ rel_sec_folder = lines[3]
+ sec_model = "stonehouse" if smodel.lower() == "on" else ""
+ if sec_model == "stonehouse":
+ sec_folder = os.path.abspath(os.path.join(solve_project_dir(self._project_dir), rel_sec_folder))
+ else:
+ sec_folder = ""
+ cfg_dict = {"host": host, "port": port, "security_model": sec_model, "security_folder": sec_folder}
+ return cfg_dict
+ else:
+ self._logger.msg_error.emit(f"cfg file '{cfg_fp}' missing.")
+ return None
+
+ def _insert_remote_engine_settings(self, settings):
+ """Inserts remote engine client settings into the settings dictionary that is delivered to the engine.
+
+ Args:
+ settings (dict): Original settings dictionary
+
+ Returns:
+ dict: Settings dictionary containing remote engine client settings
+ """
+ settings["engineSettings/remoteHost"] = self._server_config["host"]
+ settings["engineSettings/remotePort"] = self._server_config["port"]
+ settings["engineSettings/remoteSecurityModel"] = self._server_config["security_model"]
+ settings["engineSettings/remoteSecurityFolder"] = self._server_config["security_folder"]
+ return settings
+
+ def _prepare_remote_execution(self):
+ """If remote execution is enabled, makes an EngineClient for pinging and uploading the project.
+ If ping is successful, the project is uploaded to the server. If the upload is successful, the
+ server responds with a Job id, which is later used by the client to make a 'start execution'
+ request.
+
+ Returns:
+ str: Job id if server is ready for remote execution, empty string if something went wrong
+ or "1" if local execution is enabled.
+ """
+ if not self._server_config:
+ return "1"
+ host, port = self._server_config["host"], self._server_config["port"]
+ security_on = False if self._server_config["security_model"].lower() == "" else True
+ sec_model = ClientSecurityModel.STONEHOUSE if security_on else ClientSecurityModel.NONE
+ try:
+ engine_client = EngineClient(host, port, sec_model, self._server_config["security_folder"])
+ except RemoteEngineInitFailed as e:
+ self._logger.msg_error.emit(f"Server is not responding in {host}:{port}. {e}.")
+ return ""
+ engine_client.set_start_time() # Set start_time for upload operation
+ # Archive the project into a zip-file
+ dest_dir = os.path.join(self._project_dir, os.pardir) # Parent dir of project_dir
+ _, project_name = os.path.split(self._project_dir)
+ self._logger.msg.emit(f"Squeezing project {project_name} into {PROJECT_ZIP_FILENAME}.zip")
+ try:
+ ZipHandler.package(src_folder=self._project_dir, dst_folder=dest_dir, fname=PROJECT_ZIP_FILENAME)
+ except Exception as e:
+ self._logger.msg_error.emit(f"{e}")
+ engine_client.close()
+ return ""
+ project_zip_file = os.path.abspath(os.path.join(self._project_dir, os.pardir, PROJECT_ZIP_FILENAME + ".zip"))
+ if not os.path.isfile(project_zip_file):
+ self._logger.msg_error.emit(f"Project zip-file {project_zip_file} does not exist")
+ engine_client.close()
+ return ""
+ file_size = get_file_size(os.path.getsize(project_zip_file))
+ self._logger.msg_warning.emit(f"Uploading project [{file_size}] ...")
+ job_id = engine_client.upload_project(project_name, project_zip_file)
+ t = engine_client.get_elapsed_time()
+ self._logger.msg.emit(f"Upload time: {t}. Job ID: {job_id}")
+ engine_client.close()
+ return job_id
+
def headless_main(args):
"""
@@ -551,6 +664,18 @@ def _specification_dicts(project_dict, project_dir, logger):
return specification_dicts
+def solve_project_dir(pd):
+ """Makes given path object OS independent.
+
+ Args:
+ pd (Path): Path Object
+
+ Returns:
+ str: OS independent path as string.
+ """
+ return str(pd).replace(os.sep, "/")
+
+
@unique
class Status(IntEnum):
"""Status codes returned from headless execution."""
diff --git a/spinetoolbox/helpers.py b/spinetoolbox/helpers.py
index b7db7e840..3666c53b4 100644
--- a/spinetoolbox/helpers.py
+++ b/spinetoolbox/helpers.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,9 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-General helper functions and classes.
-"""
+"""General helper functions and classes."""
import functools
import time
from enum import Enum, unique
@@ -28,11 +27,13 @@
import bisect
from contextlib import contextmanager
import tempfile
+from typing import Sequence
+
import matplotlib
from PySide6.QtCore import Qt, Slot, QFile, QIODevice, QSize, QRect, QPoint, QUrl, QObject, QEvent
from PySide6.QtCore import __version__ as qt_version
from PySide6.QtCore import __version_info__ as qt_version_info
-from PySide6.QtWidgets import QApplication, QMessageBox, QFileIconProvider, QStyle, QFileDialog, QInputDialog
+from PySide6.QtWidgets import QApplication, QMessageBox, QFileIconProvider, QStyle, QFileDialog, QInputDialog, QSplitter
from PySide6.QtGui import (
QGuiApplication,
QCursor,
@@ -51,9 +52,11 @@
QColor,
QFont,
QPainter,
+ QUndoCommand,
)
from spine_engine.utils.serialization import deserialize_path
from spinedb_api.spine_io.gdx_utils import find_gams_directory
+from spinedb_api.helpers import group_consecutive
from .config import (
DEFAULT_WORK_DIR,
PLUGINS_PATH,
@@ -66,7 +69,7 @@
if os.name == "nt":
import ctypes
-matplotlib.use('Qt5Agg')
+matplotlib.use("Qt5Agg")
matplotlib.rcParams.update({"font.size": 8})
logging.getLogger("matplotlib").setLevel(logging.WARNING)
_matplotlib_version = [int(x) for x in matplotlib.__version__.split(".") if x.isdigit()]
@@ -147,7 +150,7 @@ def create_dir(base_path, folder="", verbosity=False):
def rename_dir(old_dir, new_dir, toolbox, box_title):
- """Renames directory. Called by ``ProjectItemModel.set_item_name()``
+ """Renames directory.
Args:
old_dir (str): Absolute path to directory that will be renamed
@@ -238,7 +241,7 @@ def set_taskbar_icon():
def supported_img_formats():
"""Checks if reading .ico files is supported."""
img_formats = QImageReader().supportedImageFormats()
- img_formats_str = '\n'.join(str(x) for x in img_formats)
+ img_formats_str = "\n".join(str(x) for x in img_formats)
logging.debug("Supported Image formats:\n%s", img_formats_str)
@@ -300,7 +303,7 @@ def copy_files(src_dir, dst_dir, includes=None, excludes=None):
count (int): Number of files copied
"""
if includes is None:
- includes = ['*']
+ includes = ["*"]
if excludes is None:
excludes = []
src_files = []
@@ -406,8 +409,7 @@ def format_string_list(str_list):
def rows_to_row_count_tuples(rows):
- """Breaks a list of rows into a list of (row, count) tuples to corresponding
- chunks of successive rows.
+ """Breaks a list of rows into a list of (row, count) tuples corresponding to chunks of successive rows.
Args:
rows (Iterable of int): rows
@@ -415,14 +417,7 @@ def rows_to_row_count_tuples(rows):
Returns:
list of tuple: row count tuples
"""
- rows = set(rows)
- if not rows:
- return []
- sorted_rows = sorted(rows)
- break_points = [k + 1 for k in range(len(sorted_rows) - 1) if sorted_rows[k] + 1 != sorted_rows[k + 1]]
- break_points = [0] + break_points + [len(sorted_rows)]
- ranges = [(break_points[l], break_points[l + 1]) for l in range(len(break_points) - 1)]
- return [(sorted_rows[start], stop - start) for start, stop in ranges]
+ return [(first, last - first + 1) for first, last in group_consecutive(rows)]
class IconListManager:
@@ -511,7 +506,7 @@ def __init__(self, char, color=None):
super().__init__()
self.char = char
self.color = QColor(color)
- self.font = QFont('Font Awesome 5 Free Solid')
+ self.font = QFont("Font Awesome 5 Free Solid")
def paint(self, painter, rect, mode=None, state=None):
painter.save()
@@ -871,8 +866,8 @@ def select_conda_executable(parent, line_edit):
if answer[0] == "": # Canceled
return
# Check that selected file at least starts with string 'conda'
- _, selected_file = os.path.split(answer[0])
- if not selected_file.lower().startswith("conda"):
+ if not is_valid_conda_executable(answer[0]):
+ _, selected_file = os.path.split(answer[0])
msg = "Selected file {0} is not a valid Conda executable".format(selected_file)
# noinspection PyCallByClass, PyArgumentList
QMessageBox.warning(parent, "Invalid Conda selected", msg)
@@ -880,6 +875,20 @@ def select_conda_executable(parent, line_edit):
line_edit.setText(answer[0])
+def is_valid_conda_executable(p):
+ """Checks that given path points to an existing file and the file name starts with 'conda'.
+
+ Args:
+ p (str): Absolute path to a file
+ """
+ if not os.path.isfile(p):
+ return False
+ _, filename = os.path.split(p)
+ if not filename.lower().startswith("conda"):
+ return False
+ return True
+
+
def select_certificate_directory(parent, line_edit):
"""Shows file browser and inserts selected certificate directory to given line edit.
@@ -1011,11 +1020,11 @@ def make_icon_toolbar_ss(color):
return f"QToolBar{{spacing: 0px; background: {icon_background}; padding: 3px; border-style: solid;}}"
-def color_from_index(i, count, base_hue=0.0, saturation=1.0):
+def color_from_index(i, count, base_hue=0.0, saturation=1.0, value=1.0):
golden_ratio = 0.618033988749895
h = golden_ratio * (360 / count) * i
h = ((base_hue + h) % 360) / 360
- return QColor.fromHsvF(h, saturation, 1.0, 1.0)
+ return QColor.fromHsvF(h, saturation, value, 1.0)
def unique_name(prefix, existing):
@@ -1052,21 +1061,6 @@ def unique_name(prefix, existing):
return f"{prefix} ({free})"
-def get_upgrade_db_promt_text(url, current, expected):
- text = (
- f"The database at {url} is at revision {current} and needs to be "
- f"upgraded to revision {expected} in order to be used with the current "
- "version of Spine Toolbox."
- )
- info_text = (
- "WARNING: After the upgrade, "
- "the database may no longer be used "
- "with previous versions of Spine."
- "
Do you want to upgrade the database now?"
- )
- return text, info_text
-
-
def parse_specification_file(spec_path, logger):
"""Parses specification file.
@@ -1306,6 +1300,16 @@ def wait(self):
@contextmanager
def signal_waiter(signal, condition=None, timeout=None):
+ """Gives a context manager that waits for the emission of given Qt signal.
+
+ Args:
+ signal (Any): signal to wait
+ condition (Callable, optional): a callable that takes the signal's parameters and returns True to stop waiting
+ timeout (float, optional): timeout in seconds; if None, wait indefinitely
+
+ Yields:
+ SignalWaiter: waiter instance
+ """
waiter = SignalWaiter(condition=condition, timeout=timeout)
signal.connect(waiter.trigger)
try:
@@ -1329,17 +1333,17 @@ def set_style(self, style):
self._formats.clear()
for ttype, tstyle in style:
text_format = self._formats[ttype] = QTextCharFormat()
- if tstyle['color']:
- brush = QBrush(QColor("#" + tstyle['color']))
+ if tstyle["color"]:
+ brush = QBrush(QColor("#" + tstyle["color"]))
text_format.setForeground(brush)
- if tstyle['bgcolor']:
- brush = QBrush(QColor("#" + tstyle['bgcolor']))
+ if tstyle["bgcolor"]:
+ brush = QBrush(QColor("#" + tstyle["bgcolor"]))
text_format.setBackground(brush)
- if tstyle['bold']:
+ if tstyle["bold"]:
text_format.setFontWeight(QFont.Bold)
- if tstyle['italic']:
+ if tstyle["italic"]:
text_format.setFontItalic(True)
- if tstyle['underline']:
+ if tstyle["underline"]:
text_format.setFontUnderline(True)
def yield_formats(self, text):
@@ -1391,8 +1395,11 @@ def restore_ui(window, app_settings, settings_group):
window_size = app_settings.value("windowSize")
window_pos = app_settings.value("windowPosition")
window_state = app_settings.value("windowState")
- window_maximized = app_settings.value("windowMaximized", defaultValue='false')
+ window_maximized = app_settings.value("windowMaximized", defaultValue="false")
n_screens = app_settings.value("n_screens", defaultValue=1)
+ splitter_states = {
+ splitter: app_settings.value(splitter.objectName() + "State") for splitter in window.findChildren(QSplitter)
+ }
app_settings.endGroup()
original_size = window.size()
if window_size:
@@ -1405,8 +1412,10 @@ def restore_ui(window, app_settings, settings_group):
if len(QGuiApplication.screens()) < int(n_screens):
# There are less screens available now than on previous application startup
window.move(0, 0) # Move this widget to primary screen position (0,0)
+ for splitter, state in splitter_states.items():
+ splitter.restoreState(state)
ensure_window_is_on_screen(window, original_size)
- if window_maximized == 'true':
+ if window_maximized == "true":
window.setWindowState(Qt.WindowMaximized)
@@ -1424,6 +1433,8 @@ def save_ui(window, app_settings, settings_group):
app_settings.setValue("windowState", window.saveState(version=1))
app_settings.setValue("windowMaximized", window.windowState() == Qt.WindowMaximized)
app_settings.setValue("n_screens", len(QGuiApplication.screens()))
+ for splitter in window.findChildren(QSplitter):
+ app_settings.setValue(splitter.objectName() + "State", splitter.saveState())
app_settings.endGroup()
@@ -1549,31 +1560,6 @@ def _is_metadata_item(item):
return "name" in item and "value" in item
-def separate_metadata_and_item_metadata(db_map_data):
- """Separates normal metadata items from item metadata items.
-
- Args:
- db_map_data (dict): database records
-
- Returns:
- tuple: item metadata records and metadata records
- """
- metadata_db_map_data = {}
- item_metadata_db_map_data = {}
- for db_map, items in db_map_data.items():
- metadata_items = []
- entity_metadata_items = []
- for item in items:
- if _is_metadata_item(item):
- metadata_items.append(item)
- else:
- entity_metadata_items.append(item)
- if metadata_items:
- metadata_db_map_data[db_map] = metadata_items
- item_metadata_db_map_data[db_map] = entity_metadata_items
- return item_metadata_db_map_data, metadata_db_map_data
-
-
class HTMLTagFilter(HTMLParser):
"""HTML tag filter."""
@@ -1630,3 +1616,66 @@ def solve_connection_file(connection_file, connection_file_dict):
fp.close()
return connection_file
return connection_file
+
+
+def remove_first(lst, items):
+ for x in items:
+ try:
+ lst.remove(x)
+ break
+ except ValueError:
+ pass
+
+
+class SealCommand(QUndoCommand):
+ """A 'meta' command that does not store undo data but can be used in mergeWith methods of other commands."""
+
+ def __init__(self, command_id=1):
+ """
+ Args:
+ command_id (int): command id
+ """
+ super().__init__("")
+ self._id = command_id
+
+ def redo(self):
+ self.setObsolete(True)
+
+ def id(self):
+ return self._id
+
+
+def plain_to_rich(text):
+ """Turns plain strings into rich text.
+
+ Args:
+ text (str): string to convert
+
+ Returns:
+ str: rich text string
+ """
+ return "" + text + ""
+
+
+def list_to_rich_text(data):
+ """Turns a sequence of strings into rich text list.
+
+ Args:
+ data (Sequence of str): iterable to convert
+
+ Returns:
+ str: rich text string
+ """
+ return plain_to_rich(" ".join(data))
+
+
+def plain_to_tool_tip(text):
+ """Turns plain strings into rich text and empty strings/Nones to None.
+
+ Args:
+ text (str, optional): string to convert
+
+ Returns:
+ str or NoneType: rich text string or None
+ """
+ return plain_to_rich(text) if text else None
diff --git a/spinetoolbox/kernel_fetcher.py b/spinetoolbox/kernel_fetcher.py
index be1b52943..cf4e3b9ff 100644
--- a/spinetoolbox/kernel_fetcher.py
+++ b/spinetoolbox/kernel_fetcher.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -10,7 +11,6 @@
######################################################################################################################
"""Contains a class for fetching kernel specs in a thread."""
-
import os
import json
from PySide6.QtCore import Signal, Slot, QThread
diff --git a/spinetoolbox/link.py b/spinetoolbox/link.py
index 42562a6bd..0cc31a2a9 100644
--- a/spinetoolbox/link.py
+++ b/spinetoolbox/link.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Classes for drawing graphics items on QGraphicsScene.
-"""
-
+"""Classes for drawing graphics items on QGraphicsScene."""
import functools
from math import sin, cos, pi, radians
from PySide6.QtCore import Qt, Slot, QPointF, QLineF, QRectF, QVariantAnimation
@@ -288,7 +286,7 @@ def hoverLeaveEvent(self, event):
class _SvgIcon(_IconBase):
- """A svg icon to show over a Link."""
+ """An SVG icon to show over a Link."""
def __init__(self, parent, extent, path, tooltip=None, active=False):
super().__init__(0, 0, extent, extent, parent, tooltip=tooltip, active=active)
@@ -317,7 +315,7 @@ class _TextIcon(_IconBase):
def __init__(self, parent, extent, char, tooltip=None, active=False):
super().__init__(0, 0, extent, extent, parent, tooltip=tooltip, active=active)
self._text_item = QGraphicsTextItem(self)
- font = QFont('Font Awesome 5 Free Solid', weight=QFont.Bold)
+ font = QFont("Font Awesome 5 Free Solid", weight=QFont.Bold)
self._text_item.setFont(font)
self._text_item.setDefaultTextColor(self._fg_color)
self._text_item.setPlainText(char)
@@ -370,7 +368,7 @@ def _place_icons(self):
if icon_count == 1:
self._icons[0].setPos(center - offset)
return
- points = list(_regular_poligon_points(icon_count, icon_extent, self._guide_path.angleAtPercent(0.5)))
+ points = list(_regular_polygon_points(icon_count, icon_extent, self._guide_path.angleAtPercent(0.5)))
points_center = functools.reduce(lambda a, b: a + b, points) / icon_count
offset += points_center - center
scale = icon_extent / self._icon_extent
@@ -628,12 +626,14 @@ def wake_up(self, src_connector):
view = self._toolbox.ui.graphicsView
self.tip = view.mapToScene(view.mapFromGlobal(QCursor.pos()))
self.src_connector = src_connector
- self.src_connector.scene().addItem(self)
+ scene = self.src_connector.scene()
+ scene.addItem(self)
self._stroker.setWidth(self.magic_number)
self._pen.setWidthF(self.magic_number)
self.setPen(self._pen)
self.update_geometry()
self.show()
+ scene.link_about_to_be_drawn.emit()
def sleep(self):
"""Removes this drawer from the scene, clears its source and destination connectors, and hides it.
@@ -643,6 +643,7 @@ def sleep(self):
scene.removeItem(self)
scene.link_drawer = self.src_connector = self.dst_connector = self.tip = None
self.hide()
+ scene.link_drawing_finished.emit()
class ConnectionLinkDrawer(LinkDrawerBase):
@@ -689,7 +690,7 @@ def add_link(self):
self.sleep()
-def _regular_poligon_points(n, side, initial_angle=0):
+def _regular_polygon_points(n, side, initial_angle=0):
internal_angle = 180 * (n - 2) / n
angle_inc = 180 - internal_angle
current_angle = initial_angle
diff --git a/spinetoolbox/load_project_items.py b/spinetoolbox/load_project_items.py
index e66092982..dab524c9a 100644
--- a/spinetoolbox/load_project_items.py
+++ b/spinetoolbox/load_project_items.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,23 +10,12 @@
# this program. If not, see .
######################################################################################################################
-"""
-Functions to load project item modules.
-"""
-import pathlib
+""" Functions to load project item modules. """
import importlib
-import importlib.util
-import pkgutil
-from spine_engine.project_item.project_item_info import ProjectItemInfo
-from spine_engine import __version__ as curr_engine_version
-from spinedb_api import __version__ as curr_db_api_version
-from .version import __version__ as curr_toolbox_version
-from .project_item.project_item_factory import ProjectItemFactory
def load_project_items(items_package_name):
- """
- Loads project item modules.
+ """Loads project item modules.
Args:
items_package_name (str): name of the package that contains the project items
@@ -35,38 +25,4 @@ def load_project_items(items_package_name):
while second maps item type to item factory
"""
items = importlib.import_module(items_package_name)
- items_root = pathlib.Path(items.__file__).parent
- categories = dict()
- factories = dict()
- for child in items_root.iterdir():
- if child.is_dir() and (child.joinpath("__init__.py").exists() or child.joinpath("__init__.pyc").exists()):
- spec = importlib.util.find_spec(f"{items_package_name}.{child.stem}")
- module = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(module)
- module_material = _find_module_material(module)
- if module_material is not None:
- item_type, category, factory = module_material
- categories[item_type] = category
- factories[item_type] = factory
- return categories, factories
-
-
-def _find_module_material(module):
- item_type = None
- category = None
- factory = None
- prefix = module.__name__ + "."
- for _, modname, _ in pkgutil.iter_modules(module.__path__, prefix):
- submodule = __import__(modname, fromlist="dummy")
- for name in dir(submodule):
- attr = getattr(submodule, name)
- if not isinstance(attr, type):
- continue
- if attr is not ProjectItemInfo and issubclass(attr, ProjectItemInfo):
- item_type = attr.item_type()
- category = attr.item_category()
- if attr is not ProjectItemFactory and issubclass(attr, ProjectItemFactory):
- factory = attr
- if item_type is not None and factory is not None:
- return item_type, category, factory
- return None
+ return items.item_factories()
diff --git a/spinetoolbox/log_mixin.py b/spinetoolbox/log_mixin.py
index 27a376960..e09aceae4 100644
--- a/spinetoolbox/log_mixin.py
+++ b/spinetoolbox/log_mixin.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -8,10 +9,8 @@
# 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 LogMixin.
-"""
+"""Contains LogMixin."""
from .helpers import format_log_message
diff --git a/spinetoolbox/logger_interface.py b/spinetoolbox/logger_interface.py
index e510f449d..798575e16 100644
--- a/spinetoolbox/logger_interface.py
+++ b/spinetoolbox/logger_interface.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-A logger interface.
-"""
-
+"""A logger interface."""
from PySide6.QtCore import QObject, Signal
diff --git a/spinetoolbox/main.py b/spinetoolbox/main.py
index efd598418..88e40c30a 100644
--- a/spinetoolbox/main.py
+++ b/spinetoolbox/main.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,9 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Provides the main() function.
-"""
+"""Provides the main() function."""
import os
import multiprocessing
import PySide6
@@ -44,15 +43,15 @@ def main():
logging.basicConfig(
stream=sys.stderr,
level=logging.DEBUG,
- format='%(asctime)s %(levelname)s: %(message)s',
- datefmt='%Y-%m-%d %H:%M:%S',
+ format="%(asctime)s %(levelname)s: %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
)
if not pyside6_version_check():
return 1
_add_pywin32_system32_to_path()
parser = _make_argument_parser()
args = parser.parse_args()
- if args.execute_only or args.list_items:
+ if args.execute_only or args.list_items or args.execute_remotely:
return_code = headless_main(args)
if return_code == Status.ARGUMENT_ERROR:
parser.print_usage()
@@ -94,10 +93,11 @@ def _make_argument_parser():
"-d",
"--deselect",
action="append",
- help="deselect project item ITEM for execution (takes precendence over --select)",
+ help="deselect project item ITEM for execution (takes precedence over --select)",
nargs="*",
metavar="ITEM",
)
+ parser.add_argument("--execute-remotely", help="execute remotely", action="append", metavar="SERVER CONFIG FILE")
return parser
diff --git a/spinetoolbox/metaobject.py b/spinetoolbox/metaobject.py
index 55264f250..4b98e9718 100644
--- a/spinetoolbox/metaobject.py
+++ b/spinetoolbox/metaobject.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,18 +10,16 @@
# this program. If not, see .
######################################################################################################################
-"""
-MetaObject class.
-"""
-
+"""MetaObject class."""
from PySide6.QtCore import QObject
from spine_engine.utils.helpers import shorten
class MetaObject(QObject):
- def __init__(self, name, description):
- """Class for an object which has a name, type, and some description.
+ """Class for an object which has a name, type, and some description."""
+ def __init__(self, name, description):
+ """
Args:
name (str): Object name
description (str): Object description
diff --git a/spinetoolbox/mvcmodels/__init__.py b/spinetoolbox/mvcmodels/__init__.py
index 8b96d2413..336d85acc 100644
--- a/spinetoolbox/mvcmodels/__init__.py
+++ b/spinetoolbox/mvcmodels/__init__.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
diff --git a/spinetoolbox/mvcmodels/array_model.py b/spinetoolbox/mvcmodels/array_model.py
index 00cfd6d53..f8d173e4d 100644
--- a/spinetoolbox/mvcmodels/array_model.py
+++ b/spinetoolbox/mvcmodels/array_model.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,15 +10,14 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains model for the Array editor widget.
-"""
+"""Contains model for the Array editor widget."""
import locale
from numbers import Number
import numpy
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
from spinedb_api import Array, from_database, ParameterValueFormatError, SpineDBAPIError
from .indexed_value_table_model import EXPANSE_COLOR
+from ..helpers import plain_to_tool_tip
class ArrayModel(QAbstractTableModel):
@@ -145,7 +145,7 @@ def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if row == len(self._data):
return None
element = self._data[row]
- return str(element)
+ return plain_to_tool_tip(str(element))
if role == Qt.ItemDataRole.BackgroundRole and row == len(self._data):
return EXPANSE_COLOR
return None
diff --git a/spinetoolbox/mvcmodels/compound_table_model.py b/spinetoolbox/mvcmodels/compound_table_model.py
index be9e1b84b..614874215 100644
--- a/spinetoolbox/mvcmodels/compound_table_model.py
+++ b/spinetoolbox/mvcmodels/compound_table_model.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Models that vertically concatenate two or more table models.
-"""
-
+"""Models that vertically concatenate two or more table models."""
import bisect
from PySide6.QtCore import Qt, Signal, Slot, QModelIndex, QTimer
from ..mvcmodels.minimal_table_model import MinimalTableModel
@@ -385,9 +383,10 @@ def _get_row_for_insertion(self, pos):
def _insert_row_map(self, pos, single_row_map):
if not single_row_map:
- # To trigger fetching. The QTimer is to avoid funny situations where the user enters new data
- # via the empty row model, and those rows need to be removed at the same time as we fetch the added data.
- # Doing it in the same loop cycle causes bugs.
+ # Emit layoutChanged to trigger fetching.
+ # The QTimer is to avoid funny situations where the user enters new data via the empty row model,
+ # and those rows need to be removed at the same time as we fetch the added data.
+ # Doing it in the same loop cycle was causing bugs.
QTimer.singleShot(0, self.layoutChanged.emit)
return
row = self._get_row_for_insertion(pos)
diff --git a/spinetoolbox/mvcmodels/empty_row_model.py b/spinetoolbox/mvcmodels/empty_row_model.py
index b0edf36d6..7ae1c6e8f 100644
--- a/spinetoolbox/mvcmodels/empty_row_model.py
+++ b/spinetoolbox/mvcmodels/empty_row_model.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains a table model with an empty last row.
-"""
-
+""" Contains a table model with an empty last row. """
from PySide6.QtCore import Qt, Slot, QModelIndex
from .minimal_table_model import MinimalTableModel
diff --git a/spinetoolbox/mvcmodels/file_list_models.py b/spinetoolbox/mvcmodels/file_list_models.py
index b83ae4d79..75fa33642 100644
--- a/spinetoolbox/mvcmodels/file_list_models.py
+++ b/spinetoolbox/mvcmodels/file_list_models.py
@@ -1,5 +1,6 @@
######################################################################################################################
# Copyright (C) 2017-2022 Spine project consortium
+# Copyright Spine Toolbox contributors
# This file is part of Spine Items.
# Spine Items 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)
@@ -9,12 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Common models.
-Contains a generic File list model and an Item for that model.
-Used by the Importer and Tool project items but this may be handy for other project items
-as well.
-"""
+"""Contains a generic File list model and an Item for that model."""
from collections import namedtuple
from itertools import combinations, takewhile
import json
@@ -23,6 +19,7 @@
from PySide6.QtWidgets import QFileIconProvider
from PySide6.QtGui import QStandardItemModel, QStandardItem, QPixmap, QPainter, QIcon, QColor
from spine_engine.project_item.project_item_resource import extract_packs, CmdLineArg, LabelArg
+from spinetoolbox.helpers import plain_to_rich
class FileListModel(QAbstractItemModel):
@@ -100,7 +97,7 @@ def data(self, index, role=Qt.ItemDataRole.DisplayRole):
return (
resource.path
if resource.hasfilepath
- else f"This file will be generated by {resource.provider_name} upon execution."
+ else plain_to_rich(f"This file will be generated by {resource.provider_name} upon execution.")
)
return None
diff --git a/spinetoolbox/mvcmodels/filter_checkbox_list_model.py b/spinetoolbox/mvcmodels/filter_checkbox_list_model.py
index 24345b4dc..6c030d8a8 100644
--- a/spinetoolbox/mvcmodels/filter_checkbox_list_model.py
+++ b/spinetoolbox/mvcmodels/filter_checkbox_list_model.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,26 +10,23 @@
# this program. If not, see .
######################################################################################################################
-"""
-Provides FilterCheckboxListModel for FilterWidget.
-"""
-
+"""Provides FilterCheckboxListModel for FilterWidget."""
import re
-from PySide6.QtCore import Qt, QModelIndex, QAbstractListModel
+from PySide6.QtCore import Qt, QModelIndex, QAbstractListModel, Slot
from spinetoolbox.helpers import bisect_chunks
class SimpleFilterCheckboxListModel(QAbstractListModel):
- _SELECT_ALL_STR = '(Select all)'
- _SELECT_ALL_FILTERED_STR = '(Select all filtered)'
- _EMPTY_STR = '(Empty)'
- _ADD_TO_SELECTION_STR = 'Add current selection to filter'
+ _SELECT_ALL_STR = "(Select all)"
+ _SELECT_ALL_FILTERED_STR = "(Select all filtered)"
+ _EMPTY_STR = "(Empty)"
+ _ADD_TO_SELECTION_STR = "Add current selection to filter"
def __init__(self, parent, show_empty=True):
- """Init class.
-
+ """
Args:
- parent (QWidget)
+ parent (QWidget): parent widget
+ show_empty (bool): if True, adds an empty row to the end of the list
"""
super().__init__(parent)
self._data = []
@@ -180,9 +178,13 @@ def get_not_selected(self):
return self._data_set.difference(self._selected)
def set_filter(self, filter_expression):
- if filter_expression and (isinstance(filter_expression, str) and not filter_expression.isspace()):
+ filter_expression = filter_expression.strip()
+ if filter_expression:
+ try:
+ self._filter_expression = re.compile(filter_expression)
+ except re.error:
+ return
self._action_rows[0] = self._SELECT_ALL_STR
- self._filter_expression = re.compile(filter_expression)
self._filter_index = [i for i, item in enumerate(self._data) if self.search_filter_expression(item)]
self._selected_filtered = set(self._data[i] for i in self._filter_index)
self._add_to_selection = False
@@ -282,11 +284,13 @@ class LazyFilterCheckboxListModel(SimpleFilterCheckboxListModel):
"""Extends SimpleFilterCheckboxListModel to allow for lazy loading in synch with another model."""
def __init__(self, parent, db_mngr, db_maps, fetch_parent, show_empty=True):
- """Init class.
-
+ """
Args:
- parent (SpineDBEditor)
- fetch_parent (FetchParent)
+ parent (SpineDBEditor): parent widget
+ db_mngr (SpineDBManager): database manager
+ db_maps (Sequence of DatabaseMapping): database maps
+ fetch_parent (FetchParent): fetch parent
+ show_empty (bool): if True, show an empty row at the end of the list
"""
super().__init__(parent, show_empty=show_empty)
self._db_mngr = db_mngr
@@ -315,11 +319,11 @@ class DataToValueFilterCheckboxListModel(SimpleFilterCheckboxListModel):
"""Extends SimpleFilterCheckboxListModel to allow for translating internal data to a value for display role."""
def __init__(self, parent, data_to_value, show_empty=True):
- """Init class.
-
+ """
Args:
- parent (SpineDBEditor)
+ parent (SpineDBEditor): parent widget
data_to_value (method): a method to translate item data to a value for display role
+ show_empty (bool): if True, add an empty row to the end of the list
"""
super().__init__(parent, show_empty=show_empty)
self.data_to_value = data_to_value
diff --git a/spinetoolbox/mvcmodels/filter_execution_model.py b/spinetoolbox/mvcmodels/filter_execution_model.py
index 27e22c968..d05ef20b5 100644
--- a/spinetoolbox/mvcmodels/filter_execution_model.py
+++ b/spinetoolbox/mvcmodels/filter_execution_model.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains FilterExecutionModel.
-"""
-
+"""Contains FilterExecutionModel."""
from PySide6.QtCore import Qt, QModelIndex, QAbstractListModel
diff --git a/spinetoolbox/mvcmodels/indexed_value_table_model.py b/spinetoolbox/mvcmodels/indexed_value_table_model.py
index 95d0d6ac6..d7e7f6931 100644
--- a/spinetoolbox/mvcmodels/indexed_value_table_model.py
+++ b/spinetoolbox/mvcmodels/indexed_value_table_model.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-A model for indexed parameter values, used by the parameter_value editors.
-"""
-
+"""A model for indexed parameter values, used by the parameter_value editors."""
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
from PySide6.QtGui import QColor
diff --git a/spinetoolbox/mvcmodels/map_model.py b/spinetoolbox/mvcmodels/map_model.py
index 04437bc78..d5dc860c2 100644
--- a/spinetoolbox/mvcmodels/map_model.py
+++ b/spinetoolbox/mvcmodels/map_model.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,9 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-A model for maps, used by the parameter_value editors.
-"""
+"""A model for maps, used by the parameter_value editors."""
from copy import deepcopy
from numbers import Number
from itertools import takewhile
@@ -184,7 +183,7 @@ def flags(self, index):
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
"""Returns row numbers for vertical headers and column titles for horizontal ones."""
if role != Qt.ItemDataRole.DisplayRole:
- return None
+ return super().headerData(section, orientation, role)
if orientation == Qt.Orientation.Vertical:
if section < len(self._rows):
return section + 1
diff --git a/spinetoolbox/mvcmodels/minimal_table_model.py b/spinetoolbox/mvcmodels/minimal_table_model.py
index 943525561..2a99e2b2e 100644
--- a/spinetoolbox/mvcmodels/minimal_table_model.py
+++ b/spinetoolbox/mvcmodels/minimal_table_model.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,17 +10,15 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains a minimal table model.
-"""
-
+""" Contains a minimal table model. """
from PySide6.QtCore import Qt, QModelIndex, QAbstractTableModel
class MinimalTableModel(QAbstractTableModel):
- def __init__(self, parent=None, header=None, lazy=True):
- """Table model for outlining simple tabular data.
+ """Table model for outlining simple tabular data."""
+ def __init__(self, parent=None, header=None, lazy=True):
+ """
Args:
parent (QObject, optional): the parent object
header (list of str): header labels
@@ -28,6 +27,7 @@ def __init__(self, parent=None, header=None, lazy=True):
super().__init__(parent)
if header is None:
header = []
+ self._parent = parent
self.header = header
self._main_data = list()
self._fetched = not lazy
diff --git a/spinetoolbox/mvcmodels/minimal_tree_model.py b/spinetoolbox/mvcmodels/minimal_tree_model.py
index 690346868..14b4aee41 100644
--- a/spinetoolbox/mvcmodels/minimal_tree_model.py
+++ b/spinetoolbox/mvcmodels/minimal_tree_model.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,19 +10,17 @@
# this program. If not, see .
######################################################################################################################
-"""
-Models to represent items in a tree.
-"""
+"""Models to represent items in a tree."""
from PySide6.QtCore import Qt, QAbstractItemModel, QModelIndex
class TreeItem:
"""A tree item that can fetch its children."""
- def __init__(self, model=None):
+ def __init__(self, model):
"""
Args:
- model (MinimalTreeModel, optional): The model where the item belongs.
+ model (MinimalTreeModel): The model where the item belongs.
"""
super().__init__()
self._children = []
@@ -29,10 +28,15 @@ def __init__(self, model=None):
self._parent_item = None
self._fetched = False
self._set_up_once = False
+ self._has_children_initially = False
+ self._created_children = {}
+
+ def set_has_children_initially(self, has_children_initially):
+ self._has_children_initially = has_children_initially
def has_children(self):
"""Returns whether this item has or could have children."""
- if self.can_fetch_more():
+ if self._has_children_initially:
return True
return bool(self.child_count())
@@ -40,11 +44,6 @@ def has_children(self):
def model(self):
return self._model
- @property
- def child_item_class(self):
- """Returns the type of child items. Reimplement in subclasses to return something more meaningful."""
- return TreeItem
-
@property
def children(self):
return self._children
@@ -67,30 +66,39 @@ def parent_item(self, parent_item):
if not isinstance(parent_item, TreeItem) and parent_item is not None:
raise ValueError("Parent must be instance of TreeItem or None")
self._parent_item = parent_item
- if parent_item is not None:
- self._model = parent_item.model
- self._model.destroyed.connect(lambda obj=None: self.tear_down())
+
+ def is_valid(self):
+ """Tests if item is valid.
+
+ Return:
+ bool: True if item is valid, False otherwise
+ """
+ return True
def child(self, row):
"""Returns the child at given row or None if out of bounds."""
- try:
+ if 0 <= row < len(self._children):
return self.children[row]
- except IndexError:
- return None
+ return None
def last_child(self):
"""Returns the last child."""
- return self.child(-1)
+ return self.child(self.child_count() - 1)
def child_count(self):
"""Returns the number of children."""
return len(self.children)
+ def row_count(self):
+ """Returns the number of rows, which may be different from the number of children.
+ This allows subclasses to hide children."""
+ return self.child_count()
+
def child_number(self):
"""Returns the rank of this item within its parent or -1 if it's an orphan."""
if self.parent_item:
return self.parent_item.children.index(self)
- return -1
+ return None
def find_children(self, cond=lambda child: True):
"""Returns children that meet condition expressed as a lambda function."""
@@ -108,7 +116,7 @@ def next_sibling(self):
def previous_sibling(self):
"""Returns the previous sibling or None if it's the first."""
- if self.child_number() <= 0:
+ if self.child_number() is None:
return None
return self.parent_item.child(self.child_number() - 1)
@@ -123,6 +131,9 @@ def set_up(self):
def _do_set_up(self):
"""Do stuff after the item has been inserted."""
+ def _polish_children(self, children):
+ """Polishes children just before inserting them."""
+
def insert_children(self, position, children):
"""Insert new children at given position. Returns a boolean depending on how it went.
@@ -135,9 +146,10 @@ def insert_children(self, position, children):
"""
bad_types = [type(child) for child in children if not isinstance(child, TreeItem)]
if bad_types:
- raise TypeError(f"Can't insert children of type {bad_types} to an item of type {type(self)}")
+ raise TypeError(f"Can't insert children of type {bad_types} to an item of type {type(self).__name__}")
if position < 0 or position > self.child_count():
return False
+ self._polish_children(children)
parent_index = self.index()
self.model.beginInsertRows(parent_index, position, position + len(children) - 1)
for child in children:
@@ -156,7 +168,9 @@ def tear_down(self):
"""Do stuff after the item has been removed."""
def tear_down_recursively(self):
- for child in self.children:
+ for child in self._created_children.values():
+ child.tear_down_recursively()
+ for child in self._children:
child.tear_down_recursively()
self.tear_down()
@@ -176,20 +190,12 @@ def remove_children(self, position, count):
return False
if last >= self.child_count():
last = self.child_count() - 1
- children = self.children[first : last + 1]
self.model.beginRemoveRows(self.index(), first, last)
- for child in children:
- child.parent_item = None
del self.children[first : last + 1]
self.model.endRemoveRows()
- for child in children:
- child.tear_down_recursively()
+ self._has_children_initially = False
return True
- def clear_children(self):
- """Clear children list."""
- self.children.clear()
-
# pylint: disable=no-self-use
def flags(self, column):
"""Enables the item and makes it selectable."""
@@ -287,7 +293,14 @@ def visit_all(self, index=QModelIndex(), view=None):
current = parent_item
def item_from_index(self, index):
- """Return the item corresponding to the given index."""
+ """Return the item corresponding to the given index.
+
+ Args:
+ index (QModelIndex): model index
+
+ Returns:
+ TreeItem: item at index
+ """
if index.isValid():
return index.internalPointer()
return self._invisible_root_item
@@ -302,7 +315,7 @@ def index_from_item(self, item):
QModelIndex: item's index
"""
row = item.child_number()
- if row < 0:
+ if row is None:
return QModelIndex()
return self.createIndex(row, 0, item)
@@ -331,13 +344,15 @@ def rowCount(self, parent=QModelIndex()):
if parent.column() > 0:
return 0
parent_item = self.item_from_index(parent)
- return parent_item.child_count()
+ return parent_item.row_count()
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
"""Returns the data stored under the given role for the index."""
if not index.isValid():
return None
item = self.item_from_index(index)
+ if not item.is_valid():
+ return None
return item.data(index.column(), role)
def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
diff --git a/spinetoolbox/mvcmodels/project_item_model.py b/spinetoolbox/mvcmodels/project_item_model.py
deleted file mode 100644
index af2e50660..000000000
--- a/spinetoolbox/mvcmodels/project_item_model.py
+++ /dev/null
@@ -1,358 +0,0 @@
-######################################################################################################################
-# Copyright (C) 2017-2022 Spine project consortium
-# 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 a class for storing project items.
-"""
-import logging
-from copy import copy
-from PySide6.QtCore import Qt, QModelIndex, QAbstractItemModel, Slot
-from PySide6.QtGui import QIcon, QFont
-
-from .project_tree_item import LeafProjectTreeItem
-
-
-class ProjectItemModel(QAbstractItemModel):
- def __init__(self, root, parent=None):
- """Class to store project tree items and ultimately project items in a tree structure.
-
- Args:
- root (RootProjectTreeItem): Root item for the project item tree
- parent (QObject): parent object
- """
- super().__init__(parent)
- self._root = root
- self._project = None
-
- def root(self):
- """Returns the root item."""
- return self._root
-
- def connect_to_project(self, project):
- """Connects the model to a project.
-
- Args:
- project (SpineToolboxProject): project to connect to
- """
- self.remove_leaves()
- self._project = project
- project.project_about_to_be_torn_down.connect(self.remove_leaves)
- project.item_added.connect(self._add_leaf_item)
- project.item_about_to_be_removed.connect(self._remove_leaf_item)
- project.item_renamed.connect(self._rename_item)
-
- @Slot(str)
- def _add_leaf_item(self, name):
- """Adds a leaf item to the model
-
- Args:
- name (str): project item's name
- """
- project_item = self._project.get_item(name)
- leaf_item = LeafProjectTreeItem(project_item)
- category_index = self.find_category(project_item.item_category())
- self.insert_item(leaf_item, category_index)
-
- @Slot(str)
- def _remove_leaf_item(self, name):
- """Removes a leaf item from the model.
-
- Args:
- name (str): project item's name
- """
- leaf_item = self.find_item(name).internalPointer()
- project_item = leaf_item.project_item
- category_index = self.find_category(project_item.item_category())
- self.remove_item(leaf_item, category_index)
-
- @Slot(str, str)
- def _rename_item(self, old_name, new_name):
- """Renames a leaf item.
-
- Args:
- old_name (str): item's old name
- new_name (str): item's new name
- """
- self._remove_leaf_item(old_name)
- self._add_leaf_item(new_name)
-
- def rowCount(self, parent=QModelIndex()):
- """Reimplemented rowCount method.
-
- Args:
- parent (QModelIndex): Index of parent item whose children are counted.
-
- Returns:
- int: Number of children of given parent
- """
- if not parent.isValid(): # Number of category items (children of root)
- return self.root().child_count()
- return parent.internalPointer().child_count()
-
- def columnCount(self, parent=QModelIndex()):
- """Returns model column count which is always 1."""
- return 1
-
- def flags(self, index):
- """Returns flags for the item at given index
-
- Args:
- index (QModelIndex): Flags of item at this index.
- """
- return index.internalPointer().flags()
-
- def parent(self, index=QModelIndex()):
- """Returns index of the parent of given index.
-
- Args:
- index (QModelIndex): Index of item whose parent is returned
-
- Returns:
- QModelIndex: Index of parent item
- """
- item = self.item(index)
- parent_item = item.parent()
- if not parent_item:
- return QModelIndex()
- if parent_item == self.root():
- return QModelIndex()
- # logging.debug("parent_item: {0}".format(parent_item.name))
- return self.createIndex(parent_item.row(), 0, parent_item)
-
- def index(self, row, column, parent=QModelIndex()):
- """Returns index of item with given row, column, and parent.
-
- Args:
- row (int): Item row
- column (int): Item column
- parent (QModelIndex): Parent item index
-
- Returns:
- QModelIndex: Item index
- """
- if row < 0 or row >= self.rowCount(parent):
- return QModelIndex()
- if column < 0 or column >= self.columnCount(parent):
- return QModelIndex()
- parent_item = self.item(parent)
- child = parent_item.child(row)
- if not child:
- return QModelIndex()
- return self.createIndex(row, column, child)
-
- def data(self, index, role=None):
- """Returns data in the given index according to requested role.
-
- Args:
- index (QModelIndex): Index to query
- role (int): Role to return
-
- Returns:
- object: Data depending on role.
- """
- if not index.isValid():
- return None
- item = index.internalPointer()
- if role == Qt.ItemDataRole.DisplayRole:
- return item.name
- if role == Qt.ItemDataRole.DecorationRole:
- if not hasattr(item, "project_item"):
- # item is a CategoryProjectTreeItem or root
- return None
- # item is a LeafProjectTreeItem
- icon_path = item.project_item.get_icon().icon_file
- return QIcon(icon_path)
- if role == Qt.ItemDataRole.FontRole:
- if not hasattr(item, "project_item"):
- bold_font = QFont()
- bold_font.setBold(True)
- return bold_font
- return None
-
- def item(self, index):
- """Returns item at given index.
-
- Args:
- index (QModelIndex): Index of item
-
- Returns:
- RootProjectTreeItem, CategoryProjectTreeItem or LeafProjectTreeItem: Item at given index or root project
- item if index is not valid
- """
- if not index.isValid():
- return self.root()
- return index.internalPointer()
-
- def find_category(self, category_name):
- """Returns the index of the given category name.
-
- Args:
- category_name (str): Name of category item to find
-
- Returns:
- QModelIndex: index of a category item or None if it was not found
- """
- category_names = [category.name for category in self.root().children()]
- try:
- row = category_names.index(category_name)
- except ValueError:
- logging.error("Category name %s not found in %s", category_name, category_names)
- return None
- return self.index(row, 0, QModelIndex())
-
- def find_item(self, name):
- """Returns the QModelIndex of the leaf item with the given name
-
- Args:
- name (str): The searched project item (long) name
-
- Returns:
- QModelIndex: Index of a project item with the given name or None if not found
- """
- for category in self.root().children():
- category_index = self.find_category(category.name)
- start_index = self.index(0, 0, category_index)
- matching_index = self.match(
- start_index, Qt.ItemDataRole.DisplayRole, name, 1, Qt.MatchFixedString | Qt.MatchRecursive
- )
- if not matching_index:
- pass # no match in this category
- elif len(matching_index) == 1:
- return matching_index[0]
- return None
-
- def get_item(self, name):
- """Returns leaf item with given name or None if it doesn't exist.
-
- Args:
- name (str): Project item name
-
- Returns:
- LeafProjectTreeItem, NoneType
- """
- ind = self.find_item(name)
- if ind is None:
- return None
- return self.item(ind)
-
- def category_of_item(self, name):
- """Returns the category item of the category that contains project item with given name
-
- Args:
- name (str): Project item name
-
- Returns:
- CategoryProjectTreeItem: category item or None if the category was not found
- """
- for category in self.root().children():
- for item in category.children():
- if name == item.name:
- return category
- return None
-
- def insert_item(self, item, parent=QModelIndex()):
- """Adds a new item to model. Fails if given parent is not
- a category item nor a leaf item. New item is inserted as
- the last item of its branch.
-
- Args:
- item (CategoryProjectTreeItem or LeafProjectTreeItem): Project item to add to model
- parent (QModelIndex): Parent project item
-
- Returns:
- bool: True if successful, False otherwise
- """
- parent_item = self.item(parent)
- row = self.rowCount(parent) # parent.child_count()
- self.beginInsertRows(parent, row, row)
- retval = parent_item.add_child(item)
- self.endInsertRows()
- return retval
-
- def remove_item(self, item, parent=QModelIndex()):
- """Removes item from project.
-
- Args:
- item (BaseProjectTreeItem): Item to remove
- parent (QModelIndex): Parent of item that is to be removed
-
- Returns:
- bool: True if item removed successfully, False if item removing failed
- """
- parent_item = self.item(parent)
- row = item.row()
- self.beginRemoveRows(parent, row, row)
- parent_item.remove_child(row)
- self.endRemoveRows()
-
- def items(self, category_name=None):
- """Returns a list of leaf items in model according to category name. If no category name given,
- returns all leaf items in a list.
-
- Args:
- category_name (str): Item category. Data Connections, Data Stores, Importers, Exporters, Tools or Views
- permitted.
-
- Returns:
- :obj:'list' of :obj:'LeafProjectTreeItem': Depending on category_name argument, returns all items or only
- items according to category. An empty list is returned if there are no items in the given category
- or if an unknown category name was given.
- """
- if not category_name:
- items = list()
- for category in self.root().children():
- items += category.children()
- return items
- category_index = self.find_category(category_name)
- if not category_index:
- logging.error("Category item '%s' not found", category_name)
- return list()
- return category_index.internalPointer().children()
-
- def n_items(self):
- """Returns the number of all items in the model excluding category items and root.
-
- Returns:
- int: Number of items
- """
- return len(self.items())
-
- def item_names(self):
- """Returns all leaf item names in a list.
-
- Returns:
- obj:'list' of obj:'str': Item names
- """
- return [item.name for item in self.items()]
-
- def items_per_category(self):
- """Returns a dict mapping category indexes to a list of items in that category.
-
- Returns:
- dict(QModelIndex,list(LeafProjectTreeItem))
- """
- category_inds = [self.index(row, 0) for row in range(self.rowCount())]
- return {ind: copy(ind.internalPointer().children()) for ind in category_inds}
-
- def leaf_indexes(self):
- """Yields leaf indexes."""
- for row in range(self.rowCount()):
- category_index = self.index(row, 0)
- for inner_row in range(self.rowCount(category_index)):
- yield self.index(inner_row, 0, category_index)
-
- @Slot()
- def remove_leaves(self):
- self.beginResetModel()
- for row in range(self.rowCount()):
- category_index = self.index(row, 0)
- category_index.internalPointer().children().clear()
- self.endResetModel()
diff --git a/spinetoolbox/mvcmodels/project_item_specification_models.py b/spinetoolbox/mvcmodels/project_item_specification_models.py
index 7b3914a36..6813739a6 100644
--- a/spinetoolbox/mvcmodels/project_item_specification_models.py
+++ b/spinetoolbox/mvcmodels/project_item_specification_models.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains a class for storing Tool specifications.
-"""
-
+"""Contains a class for storing Tool specifications."""
import bisect
from PySide6.QtCore import Qt, QModelIndex, QAbstractListModel, QSortFilterProxyModel, Slot, Signal
@@ -107,13 +105,6 @@ def data(self, index, role=None):
row = index.row()
if role == Qt.ItemDataRole.DisplayRole:
return self._spec_names[row]
- if role == Qt.ItemDataRole.ToolTipRole:
- if row >= self.rowCount():
- return ""
- return (
- "
Drag-and-drop this onto the Design View "
- f"to create a new {self._spec_names[row]} item.
"
- )
if role == Qt.ItemDataRole.DecorationRole:
spec = self.specification(row)
return self._icons[spec.item_type]
diff --git a/spinetoolbox/mvcmodels/project_tree_item.py b/spinetoolbox/mvcmodels/project_tree_item.py
deleted file mode 100644
index 939869715..000000000
--- a/spinetoolbox/mvcmodels/project_tree_item.py
+++ /dev/null
@@ -1,203 +0,0 @@
-######################################################################################################################
-# Copyright (C) 2017-2022 Spine project consortium
-# 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 .
-######################################################################################################################
-
-"""
-Project Tree items.
-"""
-
-import logging
-import bisect
-from PySide6.QtCore import Qt
-from spinetoolbox.metaobject import MetaObject
-
-
-class BaseProjectTreeItem(MetaObject):
- """Base class for all project tree items."""
-
- def __init__(self, name, description):
- """
- Args:
- name (str): Object name
- description (str): Object description
- """
- super().__init__(name, description)
- self._parent = None # Parent BaseProjectTreeItem. Set when add_child is called
- self._children = list() # Child BaseProjectTreeItems. Appended when new items are inserted into model.
-
- def flags(self): # pylint: disable=no-self-use
- """Returns the item flags."""
- return Qt.NoItemFlags
-
- def parent(self):
- """Returns parent project tree item."""
- return self._parent
-
- def child_count(self):
- """Returns the number of child project tree items."""
- return len(self._children)
-
- def children(self):
- """Returns the children of this project tree item."""
- return self._children
-
- def child(self, row):
- """Returns child BaseProjectTreeItem on given row.
-
- Args:
- row (int): Row of child to return
-
- Returns:
- BaseProjectTreeItem: item on given row or None if it does not exist
- """
- try:
- item = self._children[row]
- except IndexError:
- logging.error("[%s] has no child on row %s", self.name, row)
- return None
- return item
-
- def row(self):
- """Returns the row on which this item is located."""
- if self._parent is not None:
- r = self._parent.children().index(self)
- # logging.debug("{0} is on row:{1}".format(self.name, r))
- return r
- return 0
-
- def add_child(self, child_item):
- """Base method that shall be overridden in subclasses."""
- raise NotImplementedError()
-
- def remove_child(self, row):
- """Remove the child of this BaseProjectTreeItem from given row. Do not call this method directly.
- This method is called by ProjectItemTreeModel when items are removed.
-
- Args:
- row (int): Row of child to remove
-
- Returns:
- bool: True if operation succeeded, False otherwise
- """
- if row < 0 or row > len(self._children):
- return False
- child = self._children.pop(row)
- child._parent = None
- return True
-
- def custom_context_menu(self, toolbox):
- """Returns the context menu for this item. Implement in subclasses as needed.
-
- Args:
- toolbox (QWidget): The widget that is controlling the menu
-
- Returns:
- QMenu: context menu
- """
- raise NotImplementedError()
-
-
-class RootProjectTreeItem(BaseProjectTreeItem):
- """Class for the root project tree item."""
-
- def __init__(self):
- super().__init__("root", "The Root Project Tree Item.")
-
- def add_child(self, child_item):
- """Adds given category item as the child of this root project tree item. New item is added as the last item.
-
- Args:
- child_item (CategoryProjectTreeItem): Item to add
-
- Returns:
- True for success, False otherwise
- """
- if isinstance(child_item, CategoryProjectTreeItem):
- self._children.append(child_item)
- child_item._parent = self
- return True
- logging.error("You can only add a category item as a child of the root item")
- return False
-
- def custom_context_menu(self, toolbox):
- """See base class."""
- raise NotImplementedError()
-
-
-class CategoryProjectTreeItem(BaseProjectTreeItem):
- """Class for category project tree items."""
-
- def flags(self):
- """Returns the item flags."""
- return Qt.ItemIsEnabled
-
- def add_child(self, child_item):
- """Adds given project tree item as the child of this category item. New item is added as the last item.
-
- Args:
- child_item (LeafProjectTreeTreeItem): Item to add
- Returns:
- True for success, False otherwise
- """
- if not isinstance(child_item, LeafProjectTreeItem):
- logging.error("You can only add a leaf item as a child of a category item")
- return False
- key = lambda x: x.name.lower()
- pos = bisect.bisect_left([key(x) for x in self._children], key(child_item))
- self._children.insert(pos, child_item)
- child_item._parent = self
- return True
-
- def custom_context_menu(self, toolbox):
- """Returns the context menu for this item.
-
- Args:
- toolbox (ToolboxUI): Toolbox main window
-
- Returns:
- QMenu: context menu
- """
- return toolbox.item_category_context_menu()
-
-
-class LeafProjectTreeItem(BaseProjectTreeItem):
- """Class for leaf items in the project item tree."""
-
- def __init__(self, project_item):
- """
- Args:
- project_item (ProjectItem): the real project item this item represents
- """
- super().__init__(project_item.name, project_item.description)
- self._project_item = project_item
-
- @property
- def project_item(self):
- """the project item linked to this leaf"""
- return self._project_item
-
- def add_child(self, child_item):
- """See base class."""
- raise NotImplementedError()
-
- def flags(self):
- """Returns the item flags."""
- return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable
-
- def custom_context_menu(self, toolbox):
- """Returns the context menu for this item.
-
- Args:
- toolbox (ToolboxUI): Toolbox main window
-
- Returns:
- QMenu: context menu
- """
- return toolbox.project_item_context_menu(self._project_item.actions())
diff --git a/spinetoolbox/mvcmodels/resource_filter_model.py b/spinetoolbox/mvcmodels/resource_filter_model.py
index cb6de2997..d3fecb438 100644
--- a/spinetoolbox/mvcmodels/resource_filter_model.py
+++ b/spinetoolbox/mvcmodels/resource_filter_model.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,21 +10,19 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains ResourceFilterModel.
-"""
+""" Contains ResourceFilterModel. """
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QStandardItemModel, QStandardItem
+from spinedb_api.filters.alternative_filter import ALTERNATIVE_FILTER_TYPE
from spinedb_api.filters.scenario_filter import SCENARIO_FILTER_TYPE
-from spinedb_api.filters.tool_filter import TOOL_FILTER_TYPE
from ..project_commands import SetFiltersOnlineCommand
class ResourceFilterModel(QStandardItemModel):
tree_built = Signal()
_SELECT_ALL = "Select all"
- _FILTER_TYPES = {"Scenario filter": SCENARIO_FILTER_TYPE, "Tool filter": TOOL_FILTER_TYPE}
- _FILTER_TYPE_TO_TEXT = dict(zip(_FILTER_TYPES.values(), _FILTER_TYPES.keys()))
+ FILTER_TYPES = {"Scenario filter": SCENARIO_FILTER_TYPE, "Alternative filter": ALTERNATIVE_FILTER_TYPE}
+ FILTER_TYPE_TO_TEXT = dict(zip(FILTER_TYPES.values(), FILTER_TYPES.keys()))
def __init__(self, connection, project, undo_stack, logger):
"""
@@ -46,13 +45,14 @@ def connection(self):
def build_tree(self):
"""Rebuilds model's contents."""
- def append_filter_items(parent_item, filter_names, filter_type, online, online_default):
+ def append_filter_items(parent_item, filter_names, filter_type, online, online_default, enabled):
for name in filter_names[filter_type]:
filter_item = QStandardItem(name)
filter_item.setCheckState(
Qt.CheckState.Checked if online.get(name, online_default) else Qt.CheckState.Unchecked
)
- filter_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsUserCheckable)
+ filter_item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsUserCheckable)
+ filter_item.setEnabled(enabled)
parent_item.appendRow(filter_item)
self.clear()
@@ -62,7 +62,7 @@ def append_filter_items(parent_item, filter_names, filter_type, online, online_d
root_item = QStandardItem(resource_label)
root_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
self.appendRow(root_item)
- for type_label, type_ in self._FILTER_TYPES.items():
+ for type_label, type_ in self.FILTER_TYPES.items():
filter_parent = QStandardItem(type_label)
if not filters_by_type.get(type_):
no_filters_item = QStandardItem("None available")
@@ -70,15 +70,23 @@ def append_filter_items(parent_item, filter_names, filter_type, online, online_d
filter_parent.appendRow(no_filters_item)
root_item.appendRow(filter_parent)
continue
- filter_parent.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
+ filter_enabled = self._connection.is_filter_type_enabled(type_)
+ filter_parent.setFlags(Qt.ItemIsSelectable)
+ filter_parent.setEnabled(filter_enabled)
select_all_item = QStandardItem(self._SELECT_ALL)
- select_all_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsUserCheckable)
+ select_all_item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsUserCheckable)
select_all_item.setCheckState(Qt.CheckState.Unchecked)
+ select_all_item.setEnabled(filter_enabled)
filter_parent.appendRow(select_all_item)
root_item.appendRow(filter_parent)
online_filters = self._connection.online_filters(resource_label, type_)
append_filter_items(
- filter_parent, filters_by_type, type_, online_filters, self._connection.is_filter_online_by_default
+ filter_parent,
+ filters_by_type,
+ type_,
+ online_filters,
+ self._connection.is_filter_online_by_default,
+ filter_enabled,
)
self._set_all_selected_item(resource_label, filter_parent)
self.tree_built.emit()
@@ -89,12 +97,10 @@ def fetch_filters(self):
url = resource.url
if not url:
continue
- scenario_names = self._connection.get_scenario_names(url)
- if scenario_names:
- filters.setdefault(resource.label, {})[SCENARIO_FILTER_TYPE] = scenario_names
- tool_names = self._connection.get_tool_names(url)
- if tool_names:
- filters.setdefault(resource.label, {})[TOOL_FILTER_TYPE] = tool_names
+ for filter_type in (SCENARIO_FILTER_TYPE, ALTERNATIVE_FILTER_TYPE):
+ names = self._connection.get_filter_item_names(filter_type, url)
+ if names:
+ filters.setdefault(resource.label, {})[filter_type] = names
return filters
def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
@@ -114,7 +120,7 @@ def _change_filter_checked_state(self, index, is_on):
if item.hasChildren():
return
resource_type_item = item.parent()
- filter_type = self._FILTER_TYPES[resource_type_item.text()]
+ filter_type = self.FILTER_TYPES[resource_type_item.text()]
root_item = resource_type_item.parent()
resource_label = root_item.text()
if item.text() == self._SELECT_ALL:
@@ -131,7 +137,7 @@ def set_online(self, resource, filter_type, online):
Args:
resource (str): Resource label
- filter_type (str): Either SCENARIO_FILTER_TYPE or TOOL_FILTER_TYPE, for now.
+ filter_type (str): Always SCENARIO_FILTER_TYPE, for now.
online (dict): mapping from scenario/tool id to online flag
"""
self.connection.set_online(resource, filter_type, online)
@@ -159,13 +165,31 @@ def _find_filter_type_item(self, resource, filter_type):
"""
root_item = self.findItems(resource)[0]
filter_type_item = None
- filter_type_text = self._FILTER_TYPE_TO_TEXT[filter_type]
+ filter_type_text = self.FILTER_TYPE_TO_TEXT[filter_type]
for row in range(root_item.rowCount()):
filter_type_item = root_item.child(row)
if filter_type_item.text() == filter_type_text:
break
return filter_type_item
+ def filter_type_items(self, filter_type):
+ """An iterator to filter type items.
+
+ Args:
+ filter_type (str): filter type
+
+ Yields:
+ QStandardItem: filter type item
+ """
+ filter_text = self.FILTER_TYPE_TO_TEXT[filter_type]
+ root_item = self.invisibleRootItem()
+ for root_row in range(root_item.rowCount()):
+ resource_item = root_item.child(root_row)
+ for resource_row in range(resource_item.rowCount()):
+ filter_type_item = resource_item.child(resource_row)
+ if filter_type_item.text() == filter_text:
+ yield filter_type_item
+
def _set_all_selected_item(self, resource, filter_type_item, emit_data_changed=False):
"""Updates 'Select All' item's checked state.
@@ -174,7 +198,7 @@ def _set_all_selected_item(self, resource, filter_type_item, emit_data_changed=F
filter_type_item (QStandardItem): filter type item
emit_data_changed (bool): if True, emit dataChanged signal if the state was updated
"""
- online_filters = self._connection.online_filters(resource, self._FILTER_TYPES[filter_type_item.text()])
+ online_filters = self._connection.online_filters(resource, self.FILTER_TYPES[filter_type_item.text()])
all_online = all(online_filters.values())
all_selected_item = filter_type_item.child(0)
all_selected = all_selected_item.data(Qt.ItemDataRole.CheckStateRole) == Qt.CheckState.Checked.value
@@ -185,3 +209,23 @@ def _set_all_selected_item(self, resource, filter_type_item, emit_data_changed=F
self.dataChanged.emit(
all_selected_item.index(), all_selected_item.index(), [Qt.ItemDataRole.CheckStateRole]
)
+
+ def set_filter_type_enabled(self, filter_type, enabled):
+ """Enables or disables a filter type.
+
+ Args:
+ filter_type (str): filter type
+ enabled (bool): whether the filter is enabled
+ """
+ filter_text = self.FILTER_TYPE_TO_TEXT[filter_type]
+ root_item = self.invisibleRootItem()
+ for root_row in range(root_item.rowCount()):
+ resource_item = root_item.child(root_row)
+ for resource_row in range(resource_item.rowCount()):
+ filter_type_item = resource_item.child(resource_row)
+ if filter_type_item.text() != filter_text:
+ continue
+ filter_type_item.setEnabled(enabled)
+ for filter_type_row in range(filter_type_item.rowCount()):
+ filter_item = filter_type_item.child(filter_type_row)
+ filter_item.setEnabled(enabled)
diff --git a/spinetoolbox/mvcmodels/shared.py b/spinetoolbox/mvcmodels/shared.py
index 868353e84..487754ab3 100644
--- a/spinetoolbox/mvcmodels/shared.py
+++ b/spinetoolbox/mvcmodels/shared.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains stuff that is used by more than one model
-"""
-
+"""Contains stuff that is used by more than one model."""
from PySide6.QtCore import Qt
PARSED_ROLE = Qt.ItemDataRole.UserRole
diff --git a/spinetoolbox/mvcmodels/time_pattern_model.py b/spinetoolbox/mvcmodels/time_pattern_model.py
index e69a92580..652afaffd 100644
--- a/spinetoolbox/mvcmodels/time_pattern_model.py
+++ b/spinetoolbox/mvcmodels/time_pattern_model.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-A model for time patterns, used by the parameter_value editors.
-"""
-
+"""A model for time patterns, used by the parameter_value editors."""
import numpy as np
from PySide6.QtCore import QModelIndex, Qt
from PySide6.QtWidgets import QMessageBox
diff --git a/spinetoolbox/mvcmodels/time_series_model_fixed_resolution.py b/spinetoolbox/mvcmodels/time_series_model_fixed_resolution.py
index efdc727f7..339c3c77b 100644
--- a/spinetoolbox/mvcmodels/time_series_model_fixed_resolution.py
+++ b/spinetoolbox/mvcmodels/time_series_model_fixed_resolution.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-A model for fixed resolution time series, used by the parameter_value editors.
-"""
-
+"""A model for fixed resolution time series, used by the parameter_value editors."""
import numpy as np
from PySide6.QtCore import QModelIndex, Qt, Slot, QLocale
from spinedb_api import TimeSeriesFixedResolution
diff --git a/spinetoolbox/mvcmodels/time_series_model_variable_resolution.py b/spinetoolbox/mvcmodels/time_series_model_variable_resolution.py
index 2c977fcb6..683aeb799 100644
--- a/spinetoolbox/mvcmodels/time_series_model_variable_resolution.py
+++ b/spinetoolbox/mvcmodels/time_series_model_variable_resolution.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,12 +10,8 @@
# this program. If not, see .
######################################################################################################################
-"""
-A model for variable resolution time series, used by the parameter_value editors.
-"""
-
+"""A model for variable resolution time series, used by the parameter_value editors."""
import numpy as np
-
from PySide6.QtCore import QModelIndex, Qt, Slot
from spinedb_api import TimeSeriesVariableResolution
from .indexed_value_table_model import IndexedValueTableModel
@@ -63,7 +60,7 @@ def insertRows(self, row, count, parent=QModelIndex()):
if len(old_indexes) > 1:
last_time_step = last_time_stamp - old_indexes[-2]
else:
- last_time_step = np.timedelta64(1, 'h')
+ last_time_step = np.timedelta64(1, "h")
new_indexes[: len(old_indexes)] = old_indexes
for i in range(count):
@@ -78,7 +75,7 @@ def insertRows(self, row, count, parent=QModelIndex()):
if len(old_indexes) > 1:
time_step = old_indexes[1] - first_time_stamp
else:
- time_step = np.timedelta64(1, 'h')
+ time_step = np.timedelta64(1, "h")
for i in range(count):
new_indexes[i] = first_time_stamp - (count - i) * time_step
new_indexes[count:] = old_indexes
diff --git a/spinetoolbox/plotting.py b/spinetoolbox/plotting.py
index 0c54f0a47..83562b8dc 100644
--- a/spinetoolbox/plotting.py
+++ b/spinetoolbox/plotting.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,9 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Functions for plotting on PlotWidget.
-"""
+"""Functions for plotting on PlotWidget."""
import datetime
from enum import auto, Enum, unique
import math
@@ -20,14 +19,12 @@
import functools
from operator import methodcaller, itemgetter
from typing import Dict, List, Optional, Union
-
from matplotlib.patches import Patch
from matplotlib.ticker import MaxNLocator
import numpy as np
-from PySide6.QtCore import Qt, QModelIndex
-
+from PySide6.QtCore import Qt
from spinedb_api.parameter_value import NUMPY_DATETIME64_UNIT, from_database
-from spinedb_api import Array, IndexedValue, TimeSeries, DateTime
+from spinedb_api import IndexedValue, DateTime
from .mvcmodels.shared import PARSED_ROLE
from .widgets.plot_canvas import LegendPosition
from .widgets.plot_widget import PlotWidget
@@ -299,7 +296,7 @@ def plot_data(data_list, plot_widget=None, plot_type=None):
plot_widget.canvas.axes.set_title(plot_title)
for data in data_list:
if type(data.x[0]) not in (float, np.float_, int):
- plot_widget.canvas.axes.tick_params(axis='x', labelrotation=30)
+ plot_widget.canvas.axes.tick_params(axis="x", labelrotation=30)
if len(squeezed_data) > 1:
plot_widget.add_legend(legend_handles)
if needs_redraw:
@@ -701,7 +698,7 @@ def plot_db_mngr_items(items, db_maps, plot_widget=None):
Args:
items (list of dict): parameter value items
- db_maps (list of DatabaseMappingBase): database mappings corresponding to items
+ db_maps (list of DatabaseMapping): database mappings corresponding to items
plot_widget (PlotWidget, optional): widget to add plots to
"""
if not items:
@@ -718,14 +715,12 @@ def plot_db_mngr_items(items, db_maps, plot_widget=None):
except PlottingError as error:
raise PlottingError(f"Failed to plot value in {db_map.codename}: {error}")
db_name = db_map.codename
- parameter_name = item["parameter_name"]
- object_name_list = item["object_name_list"]
- if object_name_list is not None:
- object_names = tuple(object_name_list.split(","))
- else:
- object_names = (item["object_name"],)
+ parameter_name = item["parameter_definition_name"]
+ entity_byname = item["entity_byname"]
+ if not isinstance(entity_byname, tuple):
+ entity_byname = (entity_byname,)
alternative_name = item["alternative_name"]
- indexes = (db_name, parameter_name) + object_names + (alternative_name,)
+ indexes = (db_name, parameter_name) + entity_byname + (alternative_name,)
index_names = _pivot_index_names(indexes)
node = root_node
for i, index in enumerate(indexes[:-1]):
diff --git a/spinetoolbox/plugin_manager.py b/spinetoolbox/plugin_manager.py
index cb3da5048..c964e1093 100644
--- a/spinetoolbox/plugin_manager.py
+++ b/spinetoolbox/plugin_manager.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,9 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains PluginManager class.
-"""
+"""Contains PluginManager class."""
import itertools
import os
import json
@@ -44,7 +43,7 @@ def _download_file(remote, local):
def _download_plugin(plugin, plugin_local_dir):
# 1. Create paths
plugin_remote_file = plugin["url"]
- plugin_remote_dir = urljoin(plugin_remote_file, '.')
+ plugin_remote_dir = urljoin(plugin_remote_file, ".")
plugin_local_file = os.path.join(plugin_local_dir, "plugin.json")
# 2. Download and parse plugin.json file
_download_file(plugin_remote_file, plugin_local_file)
@@ -105,7 +104,6 @@ def load_installed_plugins(self):
local_data = load_specification_local_data(project.config_dir) if project else {}
for plugin_dir in plugins_dirs(self._toolbox.qsettings()):
self.load_individual_plugin(plugin_dir, local_data)
- self._toolbox.refresh_toolbars()
def reload_plugins_with_local_data(self):
"""Reloads plugins that have project specific local data."""
@@ -153,7 +151,6 @@ def load_individual_plugin(self, plugin_dir, specification_local_data):
self._plugin_specs.update(plugin_specs)
toolbar = self._plugin_toolbars[name] = PluginToolBar(name, parent=self._toolbox)
toolbar.setup(plugin_specs, disabled_plugins)
- self._toolbox.addToolBar(Qt.TopToolBarArea, toolbar)
def _create_worker(self):
worker = _PluginWorker()
diff --git a/spinetoolbox/project.py b/spinetoolbox/project.py
index 399ccdc33..b3187f113 100644
--- a/spinetoolbox/project.py
+++ b/spinetoolbox/project.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,17 +10,15 @@
# this program. If not, see .
######################################################################################################################
-"""
-Spine Toolbox project class.
-"""
+"""Spine Toolbox project class."""
from enum import auto, Enum, unique
from itertools import chain
import os
from pathlib import Path
import json
-import random
from PySide6.QtCore import Signal, QCoreApplication
from PySide6.QtGui import QColor
+from PySide6.QtWidgets import QMessageBox
import networkx as nx
from spine_engine.exception import EngineInitFailed, RemoteEngineInitFailed
from spine_engine.utils.helpers import create_timestamp, gather_leaf_data
@@ -153,10 +152,52 @@ def toolbox(self):
def all_item_names(self):
return list(self._project_items)
+ @property
+ def n_items(self):
+ return len(self.all_item_names)
+
@property
def settings(self):
return self._settings
+ def has_items(self):
+ """Returns True if project has project items.
+
+ Returns:
+ bool: True if project has items, False otherwise
+ """
+ return bool(self._project_items)
+
+ def get_item(self, name):
+ """Returns project item.
+
+ Args:
+ name (str): Item's name
+
+ Returns:
+ ProjectItem: Project item
+ """
+ return self._project_items[name]
+
+ def get_items(self):
+ """Returns all project items.
+
+ Returns:
+ list of ProjectItem: All project items
+ """
+ return list(self._project_items.values())
+
+ def get_items_by_type(self, _type):
+ """Returns all project items with given _type.
+
+ Args:
+ _type (str): Project Item type
+
+ Returns:
+ list of ProjectItem: Project Items with given type or an empty list if none found
+ """
+ return [item for item in self.get_items() if item.item_type() == _type]
+
def _create_project_structure(self, directory):
"""Makes the given directory a Spine Toolbox project directory.
Creates directories and files that are common to all projects.
@@ -284,6 +325,7 @@ def load(self, spec_factories, item_factories):
Returns:
bool: True if the operation was successful, False otherwise
"""
+ self._toolbox.ui.textBrowser_eventlog.clear()
project_dict = load_project_dict(self.config_dir, self._logger)
if project_dict is None:
return False
@@ -315,7 +357,7 @@ def load(self, spec_factories, item_factories):
self._logger.msg.emit("Loading project items...")
if not items_dict:
self._logger.msg_warning.emit("Project has no items")
- self.restore_project_items(items_dict, item_factories, silent=True)
+ self.restore_project_items(items_dict, item_factories)
self._logger.msg.emit("Restoring connections...")
connection_dicts = project_info["project"]["connections"]
connections = list(map(self.connection_from_dict, connection_dicts))
@@ -574,12 +616,11 @@ def _default_specification_file_path(self, specification):
return None
return candidate_path
- def add_item(self, item, silent=True):
- """Adds a project to item project.
+ def add_item(self, item):
+ """Adds a project item to project.
Args:
item (ProjectItem): item to add
- silent (bool): if True, don't log messages
"""
if item.name in self._project_items:
raise RuntimeError("Item already in project.")
@@ -587,35 +628,6 @@ def add_item(self, item, silent=True):
name = item.name
self.item_added.emit(name)
item.set_up()
- if not silent:
- self._logger.msg.emit(f"{item.item_type()} {name} added to project")
-
- def has_items(self):
- """Returns True if project has project items.
-
- Returns:
- bool: True if project has items, False otherwise
- """
- return bool(self._project_items)
-
- def get_item(self, name):
- """Returns project item.
-
- Args:
- name (str): item's name
-
- Returns:
- ProjectItem: project item
- """
- return self._project_items[name]
-
- def get_items(self):
- """Returns all project items.
-
- Returns:
- list of ProjectItem: all project items
- """
- return list(self._project_items.values())
def rename_item(self, previous_name, new_name, rename_data_dir_message):
"""Renames a project item
@@ -746,7 +758,24 @@ def add_connection(self, *args, silent=False, notify_resource_changes=True):
self.connection_established.emit(connection)
self._update_jump_icons()
if not self._is_dag_valid(dag):
- return True # Connection was added successfully even though DAG is not valid.
+ self.remove_connection(connection)
+ msg = "This connection creates a cycle into the DAG.\n\nWould you like to add a Loop connection?"
+ title = f"Add Loop?"
+ message_box = QMessageBox(
+ QMessageBox.Icon.Question,
+ title,
+ msg,
+ QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
+ parent=self._toolbox,
+ )
+ message_box.button(QMessageBox.StandardButton.Ok).setText("Add Loop")
+ answer = message_box.exec()
+ if answer == QMessageBox.StandardButton.Cancel:
+ return False
+ src_conn = self.get_item(connection.source).get_icon().conn_button(connection.source_position)
+ dst_conn = self.get_item(connection.destination).get_icon().conn_button(connection.destination_position)
+ self._toolbox.ui.graphicsView.add_jump(src_conn, dst_conn)
+ return False
destination = self._project_items[connection.destination]
source = self._project_items[connection.source]
if notify_resource_changes:
@@ -906,13 +935,12 @@ def dag_with_node(self, node):
"""Returns the DiGraph that contains the given node (project item) name (str)."""
return next((x for x in self._dag_iterator() if x.has_node(node)), None)
- def restore_project_items(self, items_dict, item_factories, silent):
+ def restore_project_items(self, items_dict, item_factories):
"""Restores project items from dictionary.
Args:
items_dict (dict): a mapping from item name to item dict
item_factories (dict): a mapping from item type to ProjectItemFactory
- silent (bool): if True, suppress a log messages
"""
for item_name, item_dict in items_dict.items():
try:
@@ -940,7 +968,7 @@ def restore_project_items(self, items_dict, item_factories, silent):
)
continue
project_item.copy_local_data(item_dict)
- self.add_item(project_item, silent)
+ self.add_item(project_item)
def remove_item_by_name(self, item_name, delete_data=False):
"""Removes project item by its name.
@@ -1013,7 +1041,7 @@ def _execute_dags(self, dags, execution_permits_list):
worker.finished.connect(lambda worker=worker: self._handle_engine_worker_finished(worker))
self._engine_workers.append(worker)
timestamp = create_timestamp()
- self._toolbox.start_execution(timestamp)
+ self._toolbox.make_execution_timestamp(timestamp)
# NOTE: Don't start the workers as they are created. They may finish too quickly, before the others
# are added to ``_engine_workers``, and thus ``_handle_engine_worker_finished()`` will believe
# that the project is done executing before it's fully loaded.
@@ -1106,12 +1134,12 @@ def execute_selected(self, names):
for dag in [dag for dag in self._dag_iterator() if set(names) & dag.nodes]:
more_dags = self._split_to_subdags(dag, names)
dags += more_dags
+ valid_dags = self._validate_dags(dags)
execution_permit_list = list()
- for dag in dags:
+ for dag in valid_dags:
execution_permits = {name: name in names for name in dag.nodes}
execution_permit_list.append(execution_permits)
- self._validate_dags(dags)
- self.execute_dags(dags, execution_permit_list, "Executing Selected Directed Acyclic Graphs")
+ self.execute_dags(valid_dags, execution_permit_list, "Executing Selected Directed Acyclic Graphs")
def _split_to_subdags(self, dag, selected_items):
"""Checks if given dag contains weakly connected components. If it does,
@@ -1127,8 +1155,13 @@ def _split_to_subdags(self, dag, selected_items):
"""
if len(dag.nodes) == 1:
return [dag]
+ # Get selected items that are in current dag
+ selected_items_in_this_dag = list()
+ for selected_item in list(selected_items):
+ if selected_item in list(dag.nodes()):
+ selected_items_in_this_dag.append(selected_item)
# List of Connections that have a selected item as its source or destination item
- connections = connections_to_selected_items(self._connections, set(selected_items))
+ connections = connections_to_selected_items(self._connections, set(selected_items_in_this_dag))
edges = dag_edges(connections)
d = make_dag(edges) # Make DAG as SpineEngine does it
if nx.number_weakly_connected_components(d) > 1:
@@ -1412,12 +1445,7 @@ def _update_predecessor(self, predecessor, outgoing_connections, resource_cache)
def _is_dag_valid(self, dag):
if not nx.is_directed_acyclic_graph(dag):
- edges = _edges_causing_loops(dag)
- for node in dag.nodes:
- self._project_items[node].invalidate_workflow(edges)
return False
- for node in dag.nodes:
- self._project_items[node].revalidate_workflow()
return True
def _update_ranks(self, dag):
@@ -1488,7 +1516,8 @@ def prepare_remote_execution(self):
return job_id
def finalize_remote_execution(self, job_id):
- """Sends a request to server to remove the project directory and removes the project ZIP file from client.y
+ """Sends a request to server to remove the project directory. In addition,
+ removes the project ZIP file from client machine.
Args:
job_id (str): job id
@@ -1539,31 +1568,8 @@ def node_successors(g):
return {n: list(g.successors(n)) for n in nx.topological_sort(g)}
-def _edges_causing_loops(g):
- """Returns a list of edges whose removal from g results in it becoming acyclic.
-
- Args:
- g (DiGraph)
-
- Returns:
- list
- """
- result = list()
- h = g.copy() # Let's work on a copy of the graph
- while True:
- try:
- cycle = list(nx.find_cycle(h))
- except nx.NetworkXNoCycle:
- break
- edge = random.choice(cycle)
- h.remove_edge(*edge)
- result.append(edge)
- return result
-
-
def _ranks(node_successors):
- """
- Calculates node ranks.
+ """Calculates node ranks.
Args:
node_successors (dict): a mapping from successor name to a list of predecessor names
diff --git a/spinetoolbox/project_commands.py b/spinetoolbox/project_commands.py
index fb83e802f..d6f818797 100644
--- a/spinetoolbox/project_commands.py
+++ b/spinetoolbox/project_commands.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-QUndoCommand subclasses for modifying the project.
-"""
-
+"""QUndoCommand subclasses for modifying the project."""
from PySide6.QtGui import QUndoCommand
from spine_engine.project_item.connection import Jump
@@ -32,31 +30,37 @@ def is_critical(self):
class SetItemSpecificationCommand(SpineToolboxCommand):
- def __init__(self, item, spec, old_spec):
- """Command to set the specification for a Tool.
+ """Command to set the specification for a project item."""
+ def __init__(self, item_name, spec, old_spec, project):
+ """
Args:
- item (ProjectItem): the Item
+ item_name (str): item's name
spec (ProjectItemSpecification): the new spec
old_spec (ProjectItemSpecification): the old spec
+ project (SpineToolboxProject): project
"""
super().__init__()
- self.item = item
- self.spec = spec
- self.old_spec = old_spec
- self.setText(f"set specification of {item.name}")
+ self._item_name = item_name
+ self._spec = spec
+ self._old_spec = old_spec
+ self._project = project
+ self.setText(f"set specification of {item_name}")
def redo(self):
- self.item.do_set_specification(self.spec)
+ item = self._project.get_item(self._item_name)
+ item.do_set_specification(self._spec)
def undo(self):
- self.item.do_set_specification(self.old_spec)
+ item = self._project.get_item(self._item_name)
+ item.do_set_specification(self._old_spec)
class MoveIconCommand(SpineToolboxCommand):
- def __init__(self, icon, project):
- """Command to move icons in the Design view.
+ """Command to move icons in the Design view."""
+ def __init__(self, icon, project):
+ """
Args:
icon (ProjectItemIcon): the icon
project (SpineToolboxProject): project
@@ -89,9 +93,10 @@ def _move_to(self, positions):
class SetProjectDescriptionCommand(SpineToolboxCommand):
- def __init__(self, project, description):
- """Command to set the project description.
+ """Command to set the project description."""
+ def __init__(self, project, description):
+ """
Args:
project (SpineToolboxProject): the project
description (str): The new description
@@ -110,9 +115,10 @@ def undo(self):
class AddProjectItemsCommand(SpineToolboxCommand):
- def __init__(self, project, items_dict, item_factories, silent=True):
- """Command to add items.
+ """Command to add items."""
+ def __init__(self, project, items_dict, item_factories):
+ """
Args:
project (SpineToolboxProject): the project
items_dict (dict): a mapping from item name to item dict
@@ -123,7 +129,6 @@ def __init__(self, project, items_dict, item_factories, silent=True):
self._project = project
self._items_dict = items_dict
self._item_factories = item_factories
- self._silent = silent
if not items_dict:
self.setObsolete(True)
elif len(items_dict) == 1:
@@ -132,7 +137,7 @@ def __init__(self, project, items_dict, item_factories, silent=True):
self.setText("add multiple items")
def redo(self):
- self._project.restore_project_items(self._items_dict, self._item_factories, self._silent)
+ self._project.restore_project_items(self._items_dict, self._item_factories)
def undo(self):
for item_name in self._items_dict:
@@ -140,9 +145,10 @@ def undo(self):
class RemoveAllProjectItemsCommand(SpineToolboxCommand):
- def __init__(self, project, item_factories, delete_data=False):
- """Command to remove all items from project.
+ """Command to remove all items from project."""
+ def __init__(self, project, item_factories, delete_data=False):
+ """
Args:
project (SpineToolboxProject): the project
item_factories (dict): a mapping from item type to ProjectItemFactory
@@ -161,15 +167,16 @@ def redo(self):
self._project.remove_item_by_name(name, self._delete_data)
def undo(self):
- self._project.restore_project_items(self._items_dict, self._item_factories, silent=True)
+ self._project.restore_project_items(self._items_dict, self._item_factories)
for connection_dict in self._connection_dicts:
self._project.add_connection(self._project.connection_from_dict(connection_dict), silent=True)
class RemoveProjectItemsCommand(SpineToolboxCommand):
- def __init__(self, project, item_factories, item_names, delete_data=False):
- """Command to remove items.
+ """Command to remove items."""
+ def __init__(self, project, item_factories, item_names, delete_data=False):
+ """
Args:
project (SpineToolboxProject): The project
item_factories (dict): a mapping from item type to ProjectItemFactory
@@ -200,7 +207,7 @@ def redo(self):
self._project.remove_item_by_name(name, self._delete_data)
def undo(self):
- self._project.restore_project_items(self._items_dict, self._item_factories, silent=True)
+ self._project.restore_project_items(self._items_dict, self._item_factories)
for connection_dict in self._connection_dicts:
self._project.add_connection(self._project.connection_from_dict(connection_dict), silent=True)
for jump_dict in self._jump_dicts:
@@ -208,9 +215,10 @@ def undo(self):
class RenameProjectItemCommand(SpineToolboxCommand):
- def __init__(self, project, previous_name, new_name):
- """Command to rename project items.
+ """Command to rename project items."""
+ def __init__(self, project, previous_name, new_name):
+ """
Args:
project (SpineToolboxProject): the project
previous_name (str): item's previous name
@@ -237,9 +245,10 @@ def is_critical(self):
class AddConnectionCommand(SpineToolboxCommand):
- def __init__(self, project, source_name, source_position, destination_name, destination_position):
- """Command to add connection between project items.
+ """Command to add connection between project items."""
+ def __init__(self, project, source_name, source_position, destination_name, destination_position):
+ """
Args:
project (SpineToolboxProject): project
source_name (str): source item's name
@@ -282,9 +291,10 @@ def undo(self):
class RemoveConnectionsCommand(SpineToolboxCommand):
- def __init__(self, project, connections):
- """Command to remove links.
+ """Command to remove links."""
+ def __init__(self, project, connections):
+ """
Args:
project (SpineToolboxProject): project
connections (list of LoggingConnection): the connections
@@ -311,9 +321,10 @@ def undo(self):
class AddJumpCommand(SpineToolboxCommand):
- def __init__(self, project, source_name, source_position, destination_name, destination_position):
- """Command to add a jump between project items.
+ """Command to add a jump between project items."""
+ def __init__(self, project, source_name, source_position, destination_name, destination_position):
+ """
Args:
project (SpineToolboxProject): project
source_name (str): source item's name
@@ -384,53 +395,66 @@ def undo(self):
class SetJumpConditionCommand(SpineToolboxCommand):
"""Command to set jump condition."""
- def __init__(self, jump_properties, jump, condition):
+ def __init__(self, project, jump, jump_properties, condition):
"""
Args:
- jump_properties (JumpPropertiesWidget): jump's properties tab
+ project (SpineToolboxProject): project
jump (Jump): target jump
- condition (str): jump condition
+ jump_properties (JumpPropertiesWidget): jump's properties tab
+ condition (dict): jump condition
"""
super().__init__()
+ self._project = project
self._jump_properties = jump_properties
- self._jump = jump
+ self._jump_source = jump.source
+ self._jump_destination = jump.destination
self._condition = condition
self._previous_condition = jump.condition
- self.setText("change loop condition")
+ self.setText(f"change loop condition for jump {jump.name}")
def redo(self):
- self._jump_properties.set_condition(self._jump, self._condition)
+ jump = self._project.find_jump(self._jump_source, self._jump_destination)
+ self._jump_properties.set_condition(jump, self._condition)
def undo(self):
- self._jump_properties.set_condition(self._jump, self._previous_condition)
+ jump = self._project.find_jump(self._jump_source, self._jump_destination)
+ self._jump_properties.set_condition(jump, self._previous_condition)
class UpdateJumpCmdLineArgsCommand(SpineToolboxCommand):
- def __init__(self, jump_properties, jump, cmd_line_args):
- """Command to update Jump command line args.
+ """Command to update Jump command line args."""
+ def __init__(self, project, jump, jump_properties, cmd_line_args):
+ """
Args:
+ project (SpineToolboxProject): project
+ jump (Jump): jump
jump_properties (JumpPropertiesWidget): the item
cmd_line_args (list): list of command line args
"""
super().__init__()
+ self._project = project
self._jump_properties = jump_properties
- self._jump = jump
+ self._jump_source = jump.source
+ self._jump_destination = jump.destination
self._redo_cmd_line_args = cmd_line_args
- self._undo_cmd_line_args = self._jump.cmd_line_args
- self.setText(f"change command line arguments of {jump.name}")
+ self._undo_cmd_line_args = jump.cmd_line_args
+ self.setText(f"change command line arguments of jump {jump.name}")
def redo(self):
- self._jump_properties.update_cmd_line_args(self._jump, self._redo_cmd_line_args)
+ jump = self._project.find_jump(self._jump_source, self._jump_destination)
+ self._jump_properties.update_cmd_line_args(jump, self._redo_cmd_line_args)
def undo(self):
- self._jump_properties.update_cmd_line_args(self._jump, self._undo_cmd_line_args)
+ jump = self._project.find_jump(self._jump_source, self._jump_destination)
+ self._jump_properties.update_cmd_line_args(jump, self._undo_cmd_line_args)
class SetFiltersOnlineCommand(SpineToolboxCommand):
- def __init__(self, project, connection, resource, filter_type, online):
- """Command to toggle filter value.
+ """Command to toggle filter value."""
+ def __init__(self, project, connection, resource, filter_type, online):
+ """
Args:
project (SpineToolboxProject): project
connection (Connection): connection
@@ -485,10 +509,39 @@ def undo(self):
connection.set_filter_default_online_status(not self._checked)
+class SetConnectionFilterTypeEnabled(SpineToolboxCommand):
+ """Command to enable and disable connection's filter types."""
+
+ def __init__(self, project, connection, filter_type, enabled):
+ """
+ Args:
+ project (SpineToolboxProject): project
+ connection (LoggingConnection): connection
+ filter_type (str): filter type
+ enabled (bool): whether filter type is enabled
+ """
+ super().__init__()
+ self.setText(f"change {connection.name}")
+ self._project = project
+ self._source_name = connection.source
+ self._destination_name = connection.destination
+ self._filter_type = filter_type
+ self._enabled = enabled
+
+ def redo(self):
+ connection = self._project.find_connection(self._source_name, self._destination_name)
+ connection.set_filter_type_enabled(self._filter_type, self._enabled)
+
+ def undo(self):
+ connection = self._project.find_connection(self._source_name, self._destination_name)
+ connection.set_filter_type_enabled(self._filter_type, not self._enabled)
+
+
class SetConnectionOptionsCommand(SpineToolboxCommand):
- def __init__(self, project, connection, options):
- """Command to set connection options.
+ """Command to set connection options."""
+ def __init__(self, project, connection, options):
+ """
Args:
project (SpineToolboxProject): project
connection (LoggingConnection): project
@@ -513,9 +566,10 @@ def undo(self):
class AddSpecificationCommand(SpineToolboxCommand):
- def __init__(self, project, specification, save_to_disk):
- """Command to add item specification to a project.
+ """Command to add item specification to a project."""
+ def __init__(self, project, specification, save_to_disk):
+ """
Args:
project (ToolboxUI): the toolbox
specification (ProjectItemSpecification): the spec
@@ -540,9 +594,10 @@ def undo(self):
class ReplaceSpecificationCommand(SpineToolboxCommand):
- def __init__(self, project, name, specification):
- """Command to replace item specification in project.
+ """Command to replace item specification in project."""
+ def __init__(self, project, name, specification):
+ """
Args:
project (ToolboxUI): the toolbox
name (str): the name of the spec to be replaced
@@ -569,9 +624,10 @@ def is_critical(self):
class RemoveSpecificationCommand(SpineToolboxCommand):
- def __init__(self, project, name):
- """Command to remove specs from a project.
+ """Command to remove specs from a project."""
+ def __init__(self, project, name):
+ """
Args:
project (SpineToolboxProject): the project
name (str): specification's name
@@ -590,9 +646,10 @@ def undo(self):
class SaveSpecificationAsCommand(SpineToolboxCommand):
- def __init__(self, project, name, path):
- """Command to remove item specs from a project.
+ """Command to remove item specs from a project."""
+ def __init__(self, project, name, path):
+ """
Args:
project (SpineToolboxProject): the project
name (str): specification's name
diff --git a/spinetoolbox/project_item/__init__.py b/spinetoolbox/project_item/__init__.py
index b8ab876f9..22dd1fb3b 100644
--- a/spinetoolbox/project_item/__init__.py
+++ b/spinetoolbox/project_item/__init__.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,6 +10,4 @@
# this program. If not, see .
######################################################################################################################
-"""
-This subpackage contains base classes for project items.
-"""
+"""This subpackage contains base classes for project items."""
diff --git a/spinetoolbox/project_item/logging_connection.py b/spinetoolbox/project_item/logging_connection.py
index c5d9eaa4c..30a168da8 100644
--- a/spinetoolbox/project_item/logging_connection.py
+++ b/spinetoolbox/project_item/logging_connection.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -8,10 +9,10 @@
# 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 logging connection and jump classes."""
+"""Contains logging connection and jump classes."""
from spinedb_api.filters.scenario_filter import SCENARIO_FILTER_TYPE
-from spinedb_api.filters.tool_filter import TOOL_FILTER_TYPE
+from spinedb_api.filters.alternative_filter import ALTERNATIVE_FILTER_TYPE
from spinedb_api import DatabaseMapping, SpineDBAPIError, SpineDBVersionError
from spine_engine.project_item.connection import ResourceConvertingConnection, Jump, ConnectionBase, FilterSettings
from ..log_mixin import LogMixin
@@ -20,6 +21,9 @@
from ..fetch_parent import FlexibleFetchParent
+_DATABASE_ITEM_TYPE = {ALTERNATIVE_FILTER_TYPE: "alternative", SCENARIO_FILTER_TYPE: "scenario"}
+
+
class HeadlessConnection(ResourceConvertingConnection):
"""A project item connection that is compatible with headless mode."""
@@ -55,6 +59,15 @@ def set_filter_enabled(self, resource_label, filter_type, filter_name, enabled):
)
specific_filter_settings[filter_name] = enabled
+ def set_filter_type_enabled(self, filter_type, enabled):
+ """Enables or disables a filter type.
+
+ Args:
+ filter_type (str): filter type
+ enabled (bool): True to enable the filter type, False to disable it
+ """
+ self._filter_settings.enabled_filter_types[filter_type] = enabled
+
def _convert_legacy_resource_filter_ids_to_filter_settings(self):
"""Converts legacy resource filter ids to filter settings.
@@ -80,15 +93,8 @@ def _convert_legacy_resource_filter_ids_to_filter_settings(self):
).setdefault(SCENARIO_FILTER_TYPE, {})
for row in db_map.query(db_map.scenario_sq):
specific_filter_settings[row.name]: row.id = row.id in scenario_filter_ids
- tool_filter_ids = resource_filter_ids.get(TOOL_FILTER_TYPE)
- if tool_filter_ids is not None:
- specific_filter_settings = self._filter_settings.known_filters.setdefault(
- resource.label, {}
- ).setdefault(TOOL_FILTER_TYPE, {})
- for row in db_map.query(db_map.tool_sq):
- specific_filter_settings[row.name] = row.id in tool_filter_ids
finally:
- db_map.connection.close()
+ db_map.close()
self._legacy_resource_filter_ids = None
@staticmethod
@@ -157,7 +163,7 @@ def graphics_item(self):
return self.link
def has_filters(self):
- """Returns True if connection has scenario or tool filters.
+ """Returns True if connection has any filters.
Returns:
bool: True if connection has filters, False otherwise
@@ -169,20 +175,14 @@ def has_filters(self):
db_map = self._get_db_map(url, ignore_version_error=True)
if db_map is None:
continue
- available_scenarios = {
- x["name"] for x in self._toolbox.db_mngr.get_items(db_map, "scenario", only_visible=True)
- }
- scenario_filters = self._filter_settings.known_filters.get(resource.label, {}).get(SCENARIO_FILTER_TYPE, {})
- if any(enabled for s, enabled in scenario_filters.items() if s in available_scenarios):
- return True
- if self._filter_settings.auto_online and any(name not in scenario_filters for name in available_scenarios):
- return True
- available_tools = {x["name"] for x in self._toolbox.db_mngr.get_items(db_map, "tool", only_visible=True)}
- tool_filters = self._filter_settings.known_filters.get(resource.label, {}).get(TOOL_FILTER_TYPE, {})
- if any(enabled for t, enabled in tool_filters.items() if t in available_tools):
- return True
- if self._filter_settings.auto_online and any(name not in tool_filters for name in available_tools):
- return True
+ known_filters = self._filter_settings.known_filters.get(resource.label, {})
+ for filter_type, item_type in _DATABASE_ITEM_TYPE.items():
+ available = {x["name"] for x in self._toolbox.db_mngr.get_items(db_map, item_type)}
+ filters = known_filters.get(filter_type, {})
+ if any(enabled for s, enabled in filters.items() if s in available):
+ return True
+ if self._filter_settings.auto_online and any(name not in filters for name in available):
+ return True
return False
def _get_db_map(self, url, ignore_version_error=False):
@@ -222,7 +222,7 @@ def _make_fetch_parent(self, db_map, item_type):
def _fetch_more_if_possible(self):
for db_map in self._db_maps.values():
- for item_type in ("scenario", "tool"):
+ for item_type in ("scenario",):
fetch_parent = self._make_fetch_parent(db_map, item_type)
if self._toolbox.db_mngr.can_fetch_more(db_map, fetch_parent):
self._toolbox.db_mngr.fetch_more(db_map, fetch_parent)
@@ -241,17 +241,23 @@ def receive_session_rolled_back(self, db_map):
def receive_error_msg(self, _db_map_error_log):
pass
- def get_scenario_names(self, url):
+ def get_filter_item_names(self, filter_type, url):
db_map = self._get_db_map(url)
if db_map is None:
return []
- return sorted(x["name"] for x in self._toolbox.db_mngr.get_items(db_map, "scenario", only_visible=True))
-
- def get_tool_names(self, url):
- db_map = self._get_db_map(url)
- if db_map is None:
- return []
- return sorted(x["name"] for x in self._toolbox.db_mngr.get_items(db_map, "tool", only_visible=True))
+ item_type = _DATABASE_ITEM_TYPE[filter_type]
+ return sorted(x["name"] for x in self._toolbox.db_mngr.get_items(db_map, item_type))
+
+ def _do_purge_before_writing(self, resources):
+ purged_urls = super()._do_purge_before_writing(resources)
+ committed_db_maps = set()
+ for url in purged_urls:
+ db_map = self._toolbox.db_mngr.db_map(url)
+ if db_map:
+ committed_db_maps.add(db_map)
+ if committed_db_maps:
+ self._toolbox.db_mngr.notify_session_committed(self, *committed_db_maps)
+ return purged_urls
def may_have_filters(self):
"""Returns whether this connection may have filters.
@@ -317,8 +323,8 @@ def set_online(self, resource, filter_type, online):
Args:
resource (str): Resource label
- filter_type (str): Either SCENARIO_FILTER_TYPE or TOOL_FILTER_TYPE, for now.
- online (dict): mapping from scenario/tool name to online flag
+ filter_type (str): filter type
+ online (dict): mapping from scenario name to online flag
"""
self._filter_settings.known_filters.setdefault(resource, {}).setdefault(filter_type, {}).update(online)
@@ -336,6 +342,13 @@ def refresh_resource_filter_model(self):
"""Makes resource filter mode fetch filter data from database."""
self.resource_filter_model.build_tree()
+ def set_filter_type_enabled(self, filter_type, enabled):
+ """See base class."""
+ super().set_filter_type_enabled(filter_type, enabled)
+ self.resource_filter_model.set_filter_type_enabled(filter_type, enabled)
+ if self is self._toolbox.active_link_item:
+ self._toolbox.link_properties_widgets[LoggingConnection].set_filter_type_enabled(filter_type, enabled)
+
def receive_resources_from_source(self, resources):
"""See base class."""
super().receive_resources_from_source(resources)
@@ -373,9 +386,12 @@ def _check_available_filters(self):
Returns:
FilterSettings: filter settings containing only filters that exist in source databases
"""
- filter_settings = FilterSettings(auto_online=self._filter_settings.auto_online)
+ filter_settings = FilterSettings(
+ auto_online=self._filter_settings.auto_online,
+ enabled_filter_types=self._filter_settings.enabled_filter_types,
+ )
for resource in self._resources:
- for filter_type in (SCENARIO_FILTER_TYPE, TOOL_FILTER_TYPE):
+ for filter_type in (SCENARIO_FILTER_TYPE, ALTERNATIVE_FILTER_TYPE):
online_filters = self._resource_filters_online(resource, filter_type)
if online_filters is not None:
filter_settings.known_filters.setdefault(resource.label, {})[filter_type] = online_filters
@@ -388,10 +404,8 @@ def _resource_filters_online(self, resource, filter_type):
db_map = self._get_db_map(url)
if db_map is None:
return None
- db_item_type = {SCENARIO_FILTER_TYPE: "scenario", TOOL_FILTER_TYPE: "tool"}[filter_type]
- available_filters = (
- x["name"] for x in self._toolbox.db_mngr.get_items(db_map, db_item_type, only_visible=True)
- )
+ db_item_type = _DATABASE_ITEM_TYPE[filter_type]
+ available_filters = (x["name"] for x in self._toolbox.db_mngr.get_items(db_map, db_item_type))
specific_filter_settings = self._filter_settings.known_filters.get(resource.label, {}).get(filter_type, {})
checked_specific_filter_settings = {}
for name in sorted(available_filters):
diff --git a/spinetoolbox/project_item/project_item.py b/spinetoolbox/project_item/project_item.py
index 644989799..4588449ed 100644
--- a/spinetoolbox/project_item/project_item.py
+++ b/spinetoolbox/project_item/project_item.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -8,13 +9,11 @@
# 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 base classes for project items and item factories.
-"""
+"""Contains base classes for project items and item factories."""
import os
import logging
-from PySide6.QtCore import Slot, Qt
+from PySide6.QtCore import Slot
from spine_engine.utils.helpers import shorten
from ..helpers import create_dir, open_url
from ..metaobject import MetaObject
@@ -77,14 +76,9 @@ def item_type():
"""
raise NotImplementedError()
- @staticmethod
- def item_category():
- """Item's category.
-
- Returns:
- str: category name
- """
- raise NotImplementedError()
+ @property
+ def project(self):
+ return self._project
@property
def logger(self):
@@ -164,7 +158,9 @@ def set_specification(self, specification):
"""Pushes a new SetItemSpecificationCommand to the toolbox' undo stack."""
if specification == self._specification:
return
- self._toolbox.undo_stack.push(SetItemSpecificationCommand(self, specification, self.undo_specification()))
+ self._toolbox.undo_stack.push(
+ SetItemSpecificationCommand(self.name, specification, self.undo_specification(), self._project)
+ )
def do_set_specification(self, specification):
"""Sets specification for this item. Removes specification if None given as argument.
@@ -203,8 +199,13 @@ def add_notification(self, text):
self.get_icon().exclamation_icon.add_notification(text)
def remove_notification(self, text):
+ """Remove the first notification that includes given subtext."""
self.get_icon().exclamation_icon.remove_notification(text)
+ def clear_other_notifications(self, text):
+ """Remove notifications that don't include the given subtext."""
+ self.get_icon().exclamation_icon.clear_other_notifications(text)
+
def set_rank(self, rank):
"""Set rank of this item for displaying in the design view."""
if rank is not None:
@@ -220,7 +221,7 @@ def handle_execution_successful(self, execution_direction, engine_state):
"""Performs item dependent actions after the execution item has finished successfully.
Args:
- execution_direction (str): "FORWARD" or "BACKWARD"
+ execution_direction (ExecutionDirection): ExecutionDirection.FORWARD or ExecutionDirection.BACKWARD
engine_state: engine state after item's execution
"""
@@ -305,23 +306,6 @@ def replace_resources_from_downstream(self, old, new):
new (list of ProjectItemResource): new resources
"""
- def invalidate_workflow(self, edges):
- """Notifies that this item's workflow is not acyclic.
-
- Args:
- edges (list): A list of edges that make the graph acyclic after removing them.
- """
- edges = ", ".join("{0} -> {1}".format(*edge) for edge in edges)
- self.clear_notifications()
- self.set_rank(None)
- self.add_notification(
- "The workflow defined for this item has loops and thus cannot be executed. "
- f"Possible fix: remove link(s) {edges}."
- )
-
- def revalidate_workflow(self):
- self.remove_notification("The workflow defined for this item has loops and thus cannot be executed.")
-
def item_dict(self):
"""Returns a dictionary corresponding to this item.
diff --git a/spinetoolbox/project_item/project_item_factory.py b/spinetoolbox/project_item/project_item_factory.py
index 71cdbd83b..e7825a787 100644
--- a/spinetoolbox/project_item/project_item_factory.py
+++ b/spinetoolbox/project_item/project_item_factory.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -8,9 +9,8 @@
# 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 base classes for project items and item factories.
-"""
+
+"""Contains base classes for project items and item factories."""
class ProjectItemFactory:
diff --git a/spinetoolbox/project_item/specification_editor_window.py b/spinetoolbox/project_item/specification_editor_window.py
index 2db92545b..a44c78a76 100644
--- a/spinetoolbox/project_item/specification_editor_window.py
+++ b/spinetoolbox/project_item/specification_editor_window.py
@@ -1,5 +1,6 @@
######################################################################################################################
# Copyright (C) 2017-2022 Spine project consortium
+# Copyright Spine Toolbox contributors
# This file is part of Spine Items.
# Spine Items 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)
@@ -9,10 +10,8 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains SpecificationEditorWindowBase and ChangeSpecPropertyCommand
-"""
-
+"""Contains SpecificationEditorWindowBase and ChangeSpecPropertyCommand"""
+from enum import IntEnum, unique
from PySide6.QtGui import QKeySequence, QIcon, QUndoStack, QAction, QUndoCommand
from PySide6.QtCore import Signal, Slot, Qt
from PySide6.QtWidgets import (
@@ -29,19 +28,42 @@
QToolButton,
)
from spinetoolbox.widgets.notification import ChangeNotifier, Notification
-from spinetoolbox.helpers import CharIconEngine, restore_ui, save_ui
+from spinetoolbox.helpers import CharIconEngine, restore_ui, save_ui, SealCommand
+
+
+class UniqueCommandId:
+ _NEXT_ID = 1
+
+ @classmethod
+ def unique_id(cls):
+ """Returns a new unique command id.
+
+ Returns:
+ int: unique id
+ """
+ new_id = cls._NEXT_ID
+ cls._NEXT_ID += 1
+ return new_id
+
+
+@unique
+class CommandId(IntEnum):
+ NONE = -1
+ NAME_UPDATE = UniqueCommandId.unique_id()
+ DESCRIPTION_UPDATE = UniqueCommandId.unique_id()
class ChangeSpecPropertyCommand(QUndoCommand):
"""Command to set specification properties."""
- def __init__(self, callback, new_value, old_value, cmd_name):
+ def __init__(self, callback, new_value, old_value, cmd_name, command_id=CommandId.NONE):
"""
Args:
- callback (function): Function to call to set the spec property.
- new_value (any): new value
- old_value (any): old value
+ callback (Callable): Function to call to set the spec property.
+ new_value (Any): new value
+ old_value (Any): old value
cmd_name (str): command name
+ command_id (IntEnum): command id
"""
super().__init__()
self._callback = callback
@@ -49,6 +71,8 @@ def __init__(self, callback, new_value, old_value, cmd_name):
self._old_value = old_value
self.setText(cmd_name)
self.setObsolete(new_value == old_value)
+ self._id = command_id
+ self._sealed = False
def redo(self):
self._callback(self._new_value)
@@ -56,6 +80,21 @@ def redo(self):
def undo(self):
self._callback(self._old_value)
+ def id(self):
+ return self._id.value
+
+ def mergeWith(self, other):
+ if not isinstance(other, ChangeSpecPropertyCommand):
+ self._sealed = True
+ return False
+ if self._sealed or self.id() != other.id():
+ return False
+ if self._old_value == other._new_value:
+ self.setObsolete(True)
+ else:
+ self._new_value = other._new_value
+ return True
+
class SpecificationEditorWindowBase(QMainWindow):
"""Base class for spec editors."""
@@ -118,12 +157,11 @@ def _restore_dock_widgets(self):
"""Restores dockWidgets to some default state. Called in the constructor, before restoring the ui from settings.
Reimplement in subclasses if needed."""
- def _make_new_specification(self, spec_name, exiting=None):
+ def _make_new_specification(self, spec_name):
"""Returns a ProjectItemSpecification from current form settings.
Args:
spec_name (str): Name of the spec
- exiting (bool, optional): Set as True if called when trying to exit the editor window
Returns:
ProjectItemSpecification
@@ -189,7 +227,7 @@ def _save(self, exiting=None):
return self.prompt_exit_without_saving()
self.show_error("Please enter a name for the specification.")
return False
- spec = self._make_new_specification(name, exiting)
+ spec = self._make_new_specification(name)
if spec is None:
return self.prompt_exit_without_saving() if exiting else False
if not self._original_spec_name:
@@ -290,9 +328,9 @@ def __init__(self, parent, spec, undo_stack):
self.setMovable(False)
widget = QWidget()
layout = QHBoxLayout(widget)
- layout.addWidget(QLabel("Name"))
+ layout.addWidget(QLabel("Name:"))
layout.addWidget(self._line_edit_name)
- layout.addWidget(QLabel("Description"))
+ layout.addWidget(QLabel("Description:"))
layout.addWidget(self._line_edit_description)
layout.setContentsMargins(3, 3, 3, 3)
layout.setStretchFactor(self._line_edit_name, 1)
@@ -300,7 +338,7 @@ def __init__(self, parent, spec, undo_stack):
self.addWidget(widget)
toolbox_icon = QIcon(":/symbols/Spine_symbol.png")
self.show_toolbox_action = self.addAction(toolbox_icon, "Show Spine Toolbox (Ctrl+ESC)")
- self.show_toolbox_action.setShortcut(Qt.CTRL | Qt.Key_Escape)
+ self.show_toolbox_action.setShortcut(QKeySequence(Qt.Modifier.CTRL.value | Qt.Key.Key_Escape.value))
self.menu = self._make_main_menu()
self.save_action = self.menu.addAction("Save")
self.duplicate_action = self.menu.addAction("Duplicate")
@@ -309,7 +347,7 @@ def __init__(self, parent, spec, undo_stack):
self.duplicate_action.setEnabled(self._parent.specification is not None)
self.save_action.setShortcut(QKeySequence.Save)
self.save_action.setIcon(QIcon(":/icons/menu_icons/save_solid.svg"))
- self.duplicate_action.setShortcut(QKeySequence(Qt.CTRL | Qt.Key_D))
+ self.duplicate_action.setShortcut(QKeySequence(Qt.Modifier.CTRL.value | Qt.Key.Key_D.value))
self.duplicate_action.setIcon(QIcon(":/icons/menu_icons/copy.svg"))
self.close_action.setShortcut(QKeySequence.Close)
self.close_action.setIcon(QIcon(":/icons/menu_icons/window-close.svg"))
@@ -317,8 +355,10 @@ def __init__(self, parent, spec, undo_stack):
if spec:
self.do_set_name(spec.name)
self.do_set_description(spec.description)
- self._line_edit_name.editingFinished.connect(self._set_name)
- self._line_edit_description.editingFinished.connect(self._set_description)
+ self._line_edit_name.textEdited.connect(self._update_name)
+ self._line_edit_name.editingFinished.connect(self._finish_name_editing)
+ self._line_edit_description.textEdited.connect(self._update_description)
+ self._line_edit_description.editingFinished.connect(self._finish_description_editing)
def _make_main_menu(self):
menu = QMenu(self)
@@ -328,40 +368,66 @@ def _make_main_menu(self):
menu_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
action = QAction(self)
action.triggered.connect(menu_button.showMenu)
- keys = [QKeySequence(Qt.ALT | Qt.Key_F), QKeySequence(Qt.ALT | Qt.Key_E)]
+ keys = [
+ QKeySequence(Qt.Modifier.ALT.value | Qt.Key.Key_F.value),
+ QKeySequence(Qt.Modifier.ALT.value | Qt.Key.Key_E.value),
+ ]
action.setShortcuts(keys)
self._parent.addAction(action)
keys_str = ", ".join([key.toString() for key in keys])
menu_button.setToolTip(f"
Main menu ({keys_str})
")
return menu
- @Slot()
- def _set_name(self):
- if self.name() == self._current_name:
- return
+ @Slot(str)
+ def _update_name(self, name):
+ """Pushes a command to undo stack that updates the specification name.
+
+ Args:
+ name (str): updated name
+ """
self._undo_stack.push(
- ChangeSpecPropertyCommand(self.do_set_name, self.name(), self._current_name, "change specification name")
+ ChangeSpecPropertyCommand(
+ self.do_set_name, name, self._current_name, "change specification name", CommandId.NAME_UPDATE
+ )
)
@Slot()
- def _set_description(self):
- if self.description() == self._current_description:
- return
+ def _finish_name_editing(self):
+ """Seals the last undo command."""
+ self._undo_stack.push(SealCommand(CommandId.NAME_UPDATE.value))
+
+ @Slot(str)
+ def _update_description(self, description):
+ """Pushes a command to undo stack that updates the specification description.
+
+ Args:
+ description (str): updated description
+ """
self._undo_stack.push(
ChangeSpecPropertyCommand(
self.do_set_description,
self.description(),
self._current_description,
"change specification description",
+ CommandId.DESCRIPTION_UPDATE,
)
)
+ @Slot()
+ def _finish_description_editing(self):
+ """Seals the last undo command."""
+ self._undo_stack.push(SealCommand(CommandId.DESCRIPTION_UPDATE.value))
+
def do_set_name(self, name):
+ self.name_changed.emit(name)
+ if self._line_edit_name.text() == name:
+ return
self._current_name = name
self._line_edit_name.setText(name)
- self.name_changed.emit(name)
def do_set_description(self, description):
+ if self._line_edit_description.text() == description:
+ return
self._current_description = description
self._line_edit_description.setText(description)
diff --git a/spinetoolbox/project_item_icon.py b/spinetoolbox/project_item_icon.py
index b396f14bf..de2343047 100644
--- a/spinetoolbox/project_item_icon.py
+++ b/spinetoolbox/project_item_icon.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,21 +10,16 @@
# this program. If not, see .
######################################################################################################################
-"""
-Classes for drawing graphics items on QGraphicsScene.
-"""
-
+"""Classes for drawing graphics items on QGraphicsScene."""
import math
from PySide6.QtCore import Qt, QPointF, QRectF, QLineF
from PySide6.QtWidgets import (
QGraphicsItem,
QGraphicsTextItem,
- QGraphicsSimpleTextItem,
QGraphicsPathItem,
QGraphicsEllipseItem,
QGraphicsColorizeEffect,
QGraphicsDropShadowEffect,
- QApplication,
QToolTip,
QStyle,
)
@@ -32,7 +28,6 @@
QPen,
QBrush,
QTextCursor,
- QPalette,
QTextBlockFormat,
QFont,
QPainterPath,
@@ -81,9 +76,11 @@ def __init__(self, toolbox, icon_file, icon_color):
self.rank_icon = RankIcon(self)
# Make item name graphics item.
self._name = ""
- self.name_item = QGraphicsSimpleTextItem(self._name)
+ self.name_item = QGraphicsTextItem(self._name)
self.name_item.setZValue(100)
self.set_name_attributes() # Set font, size, position, etc.
+ self.spec_item = None # For displaying Tool Spec icon
+ self.spec_item_renderer = None
# Make connector buttons
self.connectors = dict(
bottom=ConnectorButton(toolbox, self, position="bottom"),
@@ -97,6 +94,31 @@ def __init__(self, toolbox, icon_file, icon_color):
self.setGraphicsEffect(shadow_effect)
self._update_path()
+ def add_specification_icon(self, spec_icon_path):
+ """Adds an SVG icon to bottom left corner of the item icon based on Tool Specification type.
+
+ Args:
+ spec_icon_path (str): Path to icon resource file.
+ """
+ self.spec_item = QGraphicsSvgItem(self)
+ self.spec_item_renderer = QSvgRenderer()
+ loading_ok = self.spec_item_renderer.load(spec_icon_path)
+ if not loading_ok:
+ self._toolbox.msg_error.emit(f"Loading SVG icon from resource {spec_icon_path} failed")
+ return
+ size = self.spec_item_renderer.defaultSize()
+ self.spec_item.setSharedRenderer(self.spec_item_renderer)
+ self.spec_item.setElementId("")
+ dim_max = max(size.width(), size.height())
+ rect_w = 0.3 * self.rect().width() # Parent rect width
+ self.spec_item.setScale(rect_w / dim_max)
+ self.spec_item.setPos(self.sceneBoundingRect().bottomLeft() - self.spec_item.sceneBoundingRect().center())
+
+ def remove_specification_icon(self):
+ """Removes the specification icon SVG from the scene."""
+ self.spec_item.setParentItem(None)
+ self.spec_item = None
+
def rect(self):
return self._rect
@@ -126,8 +148,7 @@ def _do_update_path(self, rounded):
self._selection_halo.setPen(selection_pen)
def finalize(self, name, x, y):
- """
- Names the icon and moves it by given amount.
+ """Names the icon and moves it by a given amount.
Args:
name (str): icon's name
@@ -138,7 +159,7 @@ def finalize(self, name, x, y):
self.update_name_item(name)
def _setup(self):
- """Setup item's attributes."""
+ """Sets up item attributes."""
self.colorizer.setColor(self._icon_color)
background_color = fix_lightness_color(self._icon_color)
gradient = QRadialGradient(self._rect.center(), 1 * self._rect.width())
@@ -187,25 +208,28 @@ def name(self):
return self._name
def update_name_item(self, new_name):
- """Set a new text to name item.
+ """Sets a new text to name item.
Args:
new_name (str): icon's name
"""
self._name = new_name
- self.name_item.setText(new_name)
+ self.name_item.setPlainText(new_name)
self._reposition_name_item()
+ self.name_item.setTextWidth(100)
def set_name_attributes(self):
- """Set name QGraphicsSimpleTextItem attributes (font, size, position, etc.)"""
- # Set font size and style
+ """Sets name item attributes (font, size, style, alignment)."""
font = self.name_item.font()
font.setPixelSize(self.FONT_SIZE_PIXELS)
font.setBold(True)
self.name_item.setFont(font)
+ option = self.name_item.document().defaultTextOption()
+ option.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.name_item.document().setDefaultTextOption(option)
def _reposition_name_item(self):
- """Set name item position (centered on top of the master icon)."""
+ """Sets name item position (centered on top of the master icon)."""
main_rect = self.sceneBoundingRect()
name_rect = self.name_item.sceneBoundingRect()
self.name_item.setPos(main_rect.center().x() - name_rect.width() / 2, main_rect.y() - name_rect.height() - 4)
@@ -299,9 +323,13 @@ def update_links_geometry(self):
if not scene:
return
icon_group = scene.icon_group | {self}
- scene.dirty_links |= set(
- link for icon in icon_group for conn in icon.connectors.values() for link in conn.links
- )
+ dirty_links = set(link for icon in icon_group for conn in icon.connectors.values() for link in conn.links)
+ if not dirty_links:
+ return
+ qsettings = self._toolbox.qsettings()
+ curved_links = qsettings.value("appSettings/curvedLinks", defaultValue="false") == "true"
+ for link in dirty_links:
+ link.update_geometry(curved_links)
def mouseReleaseEvent(self, event):
"""Clears pre-bump rects, and pushes a move icon command if necessary."""
@@ -328,14 +356,14 @@ def contextMenuEvent(self, event):
event.accept()
self.scene().clearSelection()
self.setSelected(True)
- ind = self._toolbox.project_item_model.find_item(self.name())
- self._toolbox.show_project_or_item_context_menu(event.screenPos(), ind)
+ item = self._toolbox.project().get_item(self.name())
+ self._toolbox.show_project_or_item_context_menu(event.screenPos(), item)
def itemChange(self, change, value):
"""
Reacts to item removal and position changes.
- In particular, destroys the drop shadow effect when the items is removed from a scene
+ In particular, destroys the drop shadow effect when the item is removed from a scene
and keeps track of item's movements on the scene.
Args:
@@ -414,11 +442,6 @@ def _restablish_bumped_items(self):
pass
return restablished
- def select_item(self):
- """Update GUI to show the details of the selected item."""
- ind = self._toolbox.project_item_model.find_item(self.name())
- self._toolbox.ui.treeView_project.setCurrentIndex(ind)
-
def paint(self, painter, option, widget=None):
"""Sets a dashed pen if selected."""
selected = bool(option.state & QStyle.StateFlag.State_Selected)
@@ -501,7 +524,6 @@ def mousePressEvent(self, event):
if event.button() != Qt.LeftButton:
event.accept()
return
- self._parent.select_item()
self._start_link(event)
def _start_link(self, event):
@@ -571,7 +593,7 @@ def __init__(self, parent):
self._parent = parent
self._execution_state = "not started"
self._text_item = QGraphicsTextItem(self)
- font = QFont('Font Awesome 5 Free Solid')
+ font = QFont("Font Awesome 5 Free Solid")
self._text_item.setFont(font)
parent_rect = parent.rect()
self.setRect(0, 0, 0.5 * parent_rect.width(), 0.5 * parent_rect.height())
@@ -646,7 +668,7 @@ def __init__(self, parent):
super().__init__(parent)
self._parent = parent
self._notifications = list()
- font = QFont('Font Awesome 5 Free Solid')
+ font = QFont("Font Awesome 5 Free Solid")
font.setPixelSize(self.FONT_SIZE_PIXELS)
self.setFont(font)
self.setDefaultTextColor(QColor("red"))
@@ -662,6 +684,14 @@ def clear_notifications(self):
self._notifications.clear()
self.hide()
+ def clear_other_notifications(self, subtext):
+ """Remove notifications that don't include the given subtext."""
+ k = next((i for i, text in enumerate(self._notifications) if subtext not in text), None)
+ if k is not None:
+ self._notifications.pop(k)
+ if not self._notifications:
+ self.hide()
+
def add_notification(self, text):
"""Add a notification."""
self._notifications.append(text)
@@ -707,7 +737,7 @@ def __init__(self, parent):
self._parent = parent
self._rect = parent.component_rect
self.bg = QGraphicsPathItem(self)
- bg_brush = QApplication.palette().brush(QPalette.ToolTipBase)
+ bg_brush = QBrush(QColor(Qt.GlobalColor.white))
self.bg.setBrush(bg_brush)
self.bg.setFlag(QGraphicsItem.ItemStacksBehindParent)
self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=False)
diff --git a/spinetoolbox/project_settings.py b/spinetoolbox/project_settings.py
index ff5d9553f..d65f99353 100644
--- a/spinetoolbox/project_settings.py
+++ b/spinetoolbox/project_settings.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -8,8 +9,8 @@
# 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 project-specific settings."""
+"""Contains project-specific settings."""
import dataclasses
diff --git a/spinetoolbox/project_upgrader.py b/spinetoolbox/project_upgrader.py
index 199f8da33..792d84dba 100644
--- a/spinetoolbox/project_upgrader.py
+++ b/spinetoolbox/project_upgrader.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,11 +10,8 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains ProjectUpgrader class used in upgrading and converting projects
-and project dicts from earlier versions to the latest version.
-"""
-
+"""Contains ProjectUpgrader class used in upgrading and converting projects
+and project dicts from earlier versions to the latest version."""
import shutil
import os
import json
@@ -53,22 +51,36 @@ def upgrade(self, project_dict, project_dir):
f"Opening project {project_dir} failed. The project's version is {v}, while "
f"this version of Spine Toolbox supports project versions up to and "
f"including {LATEST_PROJECT_VERSION}. To open this project, you should "
- f"upgrade Spine Toolbox"
+ f"upgrade Spine Toolbox."
)
return False
if v < LATEST_PROJECT_VERSION:
+ if not self.confirm_upgrade(project_dir):
+ return False
# Back up project.json file before upgrading
if not self.backup_project_file(project_dir, v):
- self._toolbox.msg_error.emit("Upgrading project failed")
+ self._toolbox.msg_error.emit(f"Upgrading project {project_dir} failed")
return False
upgraded_dict = self.upgrade_to_latest(v, project_dict, project_dir)
# Force save project dict to project.json
if not self.force_save(upgraded_dict, project_dir):
- self._toolbox.msg_error.emit("Upgrading project failed")
+ self._toolbox.msg_error.emit(f"Upgrading project {project_dir} failed")
return False
return upgraded_dict
return project_dict
+ def confirm_upgrade(self, project_dir):
+ """Asks user whether to upgrade the project to a new version."""
+ button = QMessageBox.question(
+ self._toolbox,
+ "Upgrade project?",
+ f"Project {project_dir} needs an upgrade to work "
+ f"with this version of Spine Toolbox.
Upgrade project?",
+ )
+ if button == QMessageBox.StandardButton.Yes:
+ return True
+ return False
+
def upgrade_to_latest(self, v, project_dict, project_dir):
"""Upgrades the given project dictionary to the latest version.
@@ -80,8 +92,8 @@ def upgrade_to_latest(self, v, project_dict, project_dir):
Returns:
dict: Upgraded project dictionary
"""
- # TODO: Fix upgrade_vx_to_vx() methods so they do not depend on self._toolbox.item_factories because these are
- # TODO: going to change
+ # Note: upgrade_vx_to_vx() methods should not depend on self._toolbox.item_factories
+ # because these are likely to change
while v < LATEST_PROJECT_VERSION:
if v == 1:
project_dict = self.upgrade_v1_to_v2(project_dict, self._toolbox.item_factories)
@@ -103,6 +115,10 @@ def upgrade_to_latest(self, v, project_dict, project_dir):
project_dict = self.upgrade_v9_to_v10(project_dict)
elif v == 10:
project_dict = self.upgrade_v10_to_v11(project_dict)
+ elif v == 11:
+ project_dict = self.upgrade_v11_to_v12(project_dict)
+ elif v == 12:
+ project_dict = self.upgrade_v12_to_v13(project_dict)
v += 1
self._toolbox.msg_success.emit(f"Project upgraded to version {v}")
return project_dict
@@ -523,6 +539,44 @@ def upgrade_v10_to_v11(old):
new["project"]["settings"] = ProjectSettings().to_dict()
return new
+ @staticmethod
+ def upgrade_v11_to_v12(old):
+ """Upgrades version 11 project dictionary to version 12.
+
+ Changes:
+ 1. Julia's execution settings are now Tool Spec settings instead of global settings
+ Execution settings are local user settings so this only updates the project version
+ to make sure that these projects cannot be opened with an older Toolbox version.
+
+ Args:
+ old (dict): Version 11 project dictionary
+
+ Returns:
+ dict: Version 12 project dictionary
+ """
+ new = copy.deepcopy(old)
+ new["project"]["version"] = 12
+ return new
+
+ @staticmethod
+ def upgrade_v12_to_v13(old):
+ """Upgrades version 12 project dictionary to version 13.
+
+ Changes:
+ 1. Connections now have enabled filter types field.
+ Old projects should open just fine so this only updates the project version
+ to make sure that these projects cannot be opened with an older Toolbox version.
+
+ Args:
+ old (dict): Version 12 project dictionary
+
+ Returns:
+ dict: Version 13 project dictionary
+ """
+ new = copy.deepcopy(old)
+ new["project"]["version"] = 13
+ return new
+
@staticmethod
def make_unique_importer_specification_name(importer_name, label, k):
return f"{importer_name} - {os.path.basename(label['path'])} - {k}"
@@ -579,8 +633,8 @@ def is_valid(self, v, p):
return self.is_valid_v2_to_v8(p, v)
if 9 <= v <= 10:
return self.is_valid_v9_to_v10(p)
- if v == 11:
- return self.is_valid_v11(p)
+ if 11 <= v <= 13:
+ return self.is_valid_v11_to_v12(p)
raise NotImplementedError(f"No validity check available for version {v}")
def is_valid_v1(self, p):
@@ -709,9 +763,9 @@ def is_valid_v9_to_v10(self, p):
return False
return True
- def is_valid_v11(self, p):
+ def is_valid_v11_to_v12(self, p):
"""Checks that the given project JSON dictionary contains
- a valid version 11 Spine Toolbox project. Valid meaning, that
+ a valid version 11 or 12 Spine Toolbox project. Valid meaning, that
it contains all required keys and values are of the correct
type.
@@ -719,7 +773,7 @@ def is_valid_v11(self, p):
p (dict): Project information JSON
Returns:
- bool: True if project is a valid version 11 project, False otherwise
+ bool: True if project is a valid version 11 or 12 project, False otherwise
"""
if "project" not in p:
self._toolbox.msg_error.emit("Invalid project.json file. Key 'project' not found.")
diff --git a/spinetoolbox/qthread_pool_executor.py b/spinetoolbox/qthread_pool_executor.py
index 6dbd5e614..03d93843a 100644
--- a/spinetoolbox/qthread_pool_executor.py
+++ b/spinetoolbox/qthread_pool_executor.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,9 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Qt-based thread pool executor.
-"""
+"""Qt-based thread pool executor."""
import os
from PySide6.QtCore import QMutex, QSemaphore, QThread
@@ -20,13 +19,21 @@ class TimeOutError(Exception):
"""An exception to raise when a timeouts expire"""
+class _CustomQSemaphore(QSemaphore):
+ def tryAcquire(self, n, timeout=None):
+ if timeout is None:
+ timeout = -1
+ timeout *= 1000
+ return super().tryAcquire(n, timeout)
+
+
class QtBasedQueue:
"""A Qt-based clone of queue.Queue."""
def __init__(self):
self._items = []
self._mutex = QMutex()
- self._semafore = QSemaphore()
+ self._semafore = _CustomQSemaphore()
def put(self, item):
self._mutex.lock()
@@ -35,9 +42,6 @@ def put(self, item):
self._semafore.release()
def get(self, timeout=None):
- if timeout is None:
- timeout = -1
- timeout *= 1000
if not self._semafore.tryAcquire(1, timeout):
raise TimeOutError()
self._mutex.lock()
@@ -50,26 +54,42 @@ class QtBasedFuture:
"""A Qt-based clone of concurrent.futures.Future."""
def __init__(self):
- self._result_queue = QtBasedQueue()
- self._exception_queue = QtBasedQueue()
+ self._semafore = _CustomQSemaphore()
+ self._done = False
+ self._result = None
+ self._exception = None
+ self._done_callbacks = []
def set_result(self, result):
- self._exception_queue.put(None)
- self._result_queue.put(result)
+ self._result = result
+ self._done = True
+ self._semafore.release()
+ for callback in self._done_callbacks:
+ callback(self)
+ self._done_callbacks = []
def set_exception(self, exc):
- self._exception_queue.put(exc)
- self._result_queue.put(None)
+ self._exception = exc
+ self._done = True
+ self._semafore.release()
def result(self, timeout=None):
- result = self._result_queue.get(timeout=timeout)
- exc = self.exception(timeout=0)
- if exc is not None:
- raise exc
- return result
+ if not self._done and not self._semafore.tryAcquire(1, timeout):
+ raise TimeOutError()
+ if self._exception is not None:
+ raise self._exception
+ return self._result
def exception(self, timeout=None):
- return self._exception_queue.get(timeout=timeout)
+ if not self._done and not self._semafore.tryAcquire(1, timeout):
+ raise TimeOutError()
+ return self._exception
+
+ def add_done_callback(self, callback):
+ if self._done:
+ callback(self)
+ return
+ self._done_callbacks.append(callback)
class QtBasedThread(QThread):
@@ -119,11 +139,7 @@ def _do_work(self):
if self._shutdown:
break
future, fn, args, kwargs = request
- try:
- result = fn(*args, **kwargs)
- future.set_result(result)
- except Exception as exc: # pylint: disable=broad-except
- future.set_exception(exc)
+ _set_future_result_and_exc(future, fn, *args, **kwargs)
self._semafore.release()
def shutdown(self):
@@ -134,3 +150,21 @@ def shutdown(self):
thread = self._threads.pop()
thread.wait()
thread.deleteLater()
+
+
+class SynchronousExecutor:
+ def submit(self, fn, *args, **kwargs):
+ future = QtBasedFuture()
+ _set_future_result_and_exc(future, fn, *args, **kwargs)
+ return future
+
+ def shutdown(self):
+ pass
+
+
+def _set_future_result_and_exc(future, fn, *args, **kwargs):
+ try:
+ result = fn(*args, **kwargs)
+ future.set_result(result)
+ except Exception as exc: # pylint: disable=broad-except
+ future.set_exception(exc)
diff --git a/spinetoolbox/resources_icons_rc.py b/spinetoolbox/resources_icons_rc.py
index 3ffddd7aa..34b5ad9be 100644
--- a/spinetoolbox/resources_icons_rc.py
+++ b/spinetoolbox/resources_icons_rc.py
@@ -1,6 +1,7 @@
# Resource object code (Python 3)
######################################################################################################################
# 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)
@@ -27017,6 +27018,33 @@
style=\x22stroke-\
width:0.5\x22 />\x0d\x0a<\
/svg>\x0d\x0a\
+\x00\x00\x01\x8d\
+<\
+svg xmlns=\x22http:\
+//www.w3.org/200\
+0/svg\x22 viewBox=\x22\
+0 0 512 512\x22>\
\x00\x00\x014\
<\
svg xmlns=\x22http:\
@@ -30168,6 +30196,41 @@
\x22\x0d\x0a style=\x22f\
ill:#ff0000\x22 />\x0d\
\x0a\x0d\x0a\
+\x00\x00\x02\x0f\
+<\
+svg xmlns=\x22http:\
+//www.w3.org/200\
+0/svg\x22 viewBox=\x22\
+0 0 384 512\x22>\
\x00\x00\x01\x1b\
<\
svg xmlns=\x22http:\
@@ -31804,6 +31867,10 @@
\x0bR\x1e\xf3\
\x00m\
\x00e\x00n\x00u\x00_\x00i\x00c\x00o\x00n\x00s\
+\x00\x09\
+\x08\x88\xa9\x07\
+\x00s\
+\x00h\x00a\x00r\x00e\x00.\x00s\x00v\x00g\
\x00\x12\
\x0an5\x93\
\x00p\
@@ -31975,6 +32042,11 @@
\x01\x80\x9cg\
\x00c\
\x00o\x00g\x00_\x00m\x00i\x00n\x00u\x00s\x00.\x00s\x00v\x00g\
+\x00\x12\
+\x03\xedqg\
+\x00b\
+\x00o\x00l\x00t\x00-\x00l\x00i\x00g\x00h\x00t\x00n\x00i\x00n\x00g\x00.\x00s\x00v\
+\x00g\
\x00\x09\
\x09(\xae'\
\x00t\
@@ -32056,9 +32128,9 @@
qt_resource_struct = b"\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x04\x00\x00\x00\x01\
\x00\x00\x00\x00\x00\x00\x00\x00\
-\x00\x00\x00\x10\x00\x02\x00\x00\x00\x03\x00\x00\x00X\
+\x00\x00\x00\x10\x00\x02\x00\x00\x00\x03\x00\x00\x00Z\
\x00\x00\x00\x00\x00\x00\x00\x00\
-\x00\x00\x00\x00\x00\x02\x00\x00\x00\x19\x00\x00\x00\x09\
+\x00\x00\x00\x00\x00\x02\x00\x00\x00\x1a\x00\x00\x00\x09\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00 \x00\x02\x00\x00\x00\x03\x00\x00\x00\x06\
\x00\x00\x00\x00\x00\x00\x00\x00\
@@ -32076,21 +32148,21 @@
\x00\x00\x01\x8aE\xfa\xa6P\
\x00\x00\x01\xea\x00\x00\x00\x00\x00\x01\x00\x062\x92\
\x00\x00\x01\x8aE\xfa\xa6\xa3\
-\x00\x00\x04X\x00\x00\x00\x00\x00\x01\x00\x06\xa7<\
+\x00\x00\x04p\x00\x00\x00\x00\x00\x01\x00\x06\xa8\xcd\
\x00\x00\x01\x8aE\xfa\xa6P\
-\x00\x00\x04\xa0\x00\x00\x00\x00\x00\x01\x00\x06\xbc&\
+\x00\x00\x04\xb8\x00\x00\x00\x00\x00\x01\x00\x06\xbd\xb7\
\x00\x00\x01\x8aE\xfa\xa6\x82\
\x00\x00\x03D\x00\x00\x00\x00\x00\x01\x00\x06\x88\x1a\
\x00\x00\x01\x8aE\xfa\xa6\xa3\
-\x00\x00\x04\x80\x00\x00\x00\x00\x00\x01\x00\x06\xa9\x10\
+\x00\x00\x04\x98\x00\x00\x00\x00\x00\x01\x00\x06\xaa\xa1\
\x00\x00\x01\x8aE\xfa\xa6P\
-\x00\x00\x03\xf4\x00\x00\x00\x00\x00\x01\x00\x06\x98\x16\
+\x00\x00\x04\x0c\x00\x00\x00\x00\x00\x01\x00\x06\x99\xa7\
\x00\x00\x01\x8aE\xfa\xa6\x82\
\x00\x00\x02\x80\x00\x00\x00\x00\x00\x01\x00\x06E\xb2\
\x00\x00\x01\x8aE\xfa\xa6P\
\x00\x00\x02h\x00\x00\x00\x00\x00\x01\x00\x06B\xab\
\x00\x00\x01\x8aE\xfa\xa6\xa3\
-\x00\x00\x03\xde\x00\x00\x00\x00\x00\x01\x00\x06\x95\xa7\
+\x00\x00\x03\xf6\x00\x00\x00\x00\x00\x01\x00\x06\x978\
\x00\x00\x01\x8aE\xfa\xa6_\
\x00\x00\x03^\x00\x00\x00\x00\x00\x01\x00\x06\x8a3\
\x00\x00\x01\x8aE\xfa\xa6P\
@@ -32102,19 +32174,21 @@
\x00\x00\x01\x8aE\xfa\xa6P\
\x00\x00\x01\xbe\x00\x00\x00\x00\x00\x01\x00\x060\xaf\
\x00\x00\x01\x8aE\xfa\xa6\x91\
+\x00\x00\x03\x98\x00\x00\x00\x00\x00\x01\x00\x06\x94o\
+\x00\x00\x01\x8e\xd1T\x9c\xe7\
\x00\x00\x01\xa6\x00\x00\x00\x00\x00\x01\x00\x06/\x9c\
\x00\x00\x01\x8aE\xfa\xa6\x92\
-\x00\x00\x04\x0c\x00\x00\x00\x00\x00\x01\x00\x06\x9e\x7f\
+\x00\x00\x04$\x00\x00\x00\x00\x00\x01\x00\x06\xa0\x10\
\x00\x00\x01\x8aE\xfa\xa6\x92\
-\x00\x00\x03\x98\x00\x02\x00\x00\x00\x09\x00\x00\x00O\
+\x00\x00\x03\xb0\x00\x02\x00\x00\x00\x09\x00\x00\x00Q\
\x00\x00\x00\x00\x00\x00\x00\x00\
-\x00\x00\x03~\x00\x02\x00\x00\x00-\x00\x00\x00\x22\
+\x00\x00\x03~\x00\x02\x00\x00\x00.\x00\x00\x00#\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x02\x0a\x00\x00\x00\x00\x00\x01\x00\x065G\
\x00\x00\x01\x8aE\xfa\xa6\xa3\
-\x00\x00\x04<\x00\x00\x00\x00\x00\x01\x00\x06\xa1\x98\
+\x00\x00\x04T\x00\x00\x00\x00\x00\x01\x00\x06\xa3)\
\x00\x00\x01\x8aE\xfa\xa6\xa3\
-\x00\x00\x03\xc2\x00\x00\x00\x00\x00\x01\x00\x06\x94o\
+\x00\x00\x03\xda\x00\x00\x00\x00\x00\x01\x00\x06\x96\x00\
\x00\x00\x01\x8aE\xfa\xa6P\
\x00\x00\x02\xda\x00\x00\x00\x00\x00\x01\x00\x06T\x05\
\x00\x00\x01\x8aE\xfa\xa6P\
@@ -32122,113 +32196,115 @@
\x00\x00\x01\x8aE\xfa\xa6\xa3\
\x00\x00\x03\x02\x00\x00\x00\x00\x00\x01\x00\x06`G\
\x00\x00\x01\x8aE\xfa\xa6P\
-\x00\x00\x02R\x00\x00\x00\x00\x00\x01\x00\x07\x05\xa0\
+\x00\x00\x02R\x00\x00\x00\x00\x00\x01\x00\x07\x071\
\x00\x00\x01\x8aE\xfa\xa6q\
-\x00\x00\x0b\x18\x00\x00\x00\x00\x00\x01\x00\x07\xad\x12\
+\x00\x00\x0bZ\x00\x00\x00\x00\x00\x01\x00\x07\xb0\xb6\
\x00\x00\x01\x8aE\xfa\xa6\x82\
-\x00\x00\x06\x18\x00\x00\x00\x00\x00\x01\x00\x06\xebN\
+\x00\x00\x060\x00\x00\x00\x00\x00\x01\x00\x06\xec\xdf\
\x00\x00\x01\x8aE\xfa\xa6\x82\
-\x00\x00\x08\xba\x00\x00\x00\x00\x00\x01\x00\x07A\xae\
+\x00\x00\x08\xd2\x00\x00\x00\x00\x00\x01\x00\x07C?\
\x00\x00\x01\x8aE\xfa\xa6`\
-\x00\x00\x09\x16\x00\x00\x00\x00\x00\x01\x00\x07Vm\
+\x00\x00\x09X\x00\x00\x00\x00\x00\x01\x00\x07Z\x11\
\x00\x00\x01\x8aE\xfa\xa6`\
-\x00\x00\x06\xba\x00\x00\x00\x00\x00\x01\x00\x07\x03D\
+\x00\x00\x06\xd2\x00\x00\x00\x00\x00\x01\x00\x07\x04\xd5\
\x00\x00\x01\x8aE\xfa\xa6\x82\
-\x00\x00\x08>\x00\x00\x00\x00\x00\x01\x00\x07;\xea\
+\x00\x00\x08V\x00\x00\x00\x00\x00\x01\x00\x07={\
\x00\x00\x01\x8aE\xfa\xa6\x82\
-\x00\x00\x08\xf2\x00\x00\x00\x00\x00\x01\x00\x07T\xab\
+\x00\x00\x094\x00\x00\x00\x00\x00\x01\x00\x07XO\
\x00\x00\x01\x8aE\xfa\xa6q\
-\x00\x00\x0a2\x00\x00\x00\x00\x00\x01\x00\x07\x94\xae\
+\x00\x00\x0at\x00\x00\x00\x00\x00\x01\x00\x07\x98R\
\x00\x00\x01\x8aE\xfa\xa6q\
-\x00\x00\x07F\x00\x00\x00\x00\x00\x01\x00\x07\x17\xb0\
+\x00\x00\x07^\x00\x00\x00\x00\x00\x01\x00\x07\x19A\
\x00\x00\x01\x8aE\xfa\xa6\x82\
-\x00\x00\x06\xda\x00\x00\x00\x00\x00\x01\x00\x07\x08\xe0\
+\x00\x00\x06\xf2\x00\x00\x00\x00\x00\x01\x00\x07\x0aq\
\x00\x00\x01\x8aE\xfa\xa6q\
-\x00\x00\x08\x1e\x00\x00\x00\x00\x00\x01\x00\x07\x22\xda\
+\x00\x00\x08\xf2\x00\x00\x00\x00\x00\x01\x00\x07U\x1d\
+\x00\x00\x01\x8e\xd1T\x9c\xe6\
+\x00\x00\x086\x00\x00\x00\x00\x00\x01\x00\x07$k\
\x00\x00\x01\x8aE\xfa\xa6`\
-\x00\x00\x08`\x00\x00\x00\x00\x00\x01\x00\x07=\xf7\
+\x00\x00\x08x\x00\x00\x00\x00\x00\x01\x00\x07?\x88\
\x00\x00\x01\x8aE\xfa\xa6q\
-\x00\x00\x07\x98\x00\x00\x00\x00\x00\x01\x00\x07\x1d\xab\
+\x00\x00\x07\xb0\x00\x00\x00\x00\x00\x01\x00\x07\x1f<\
\x00\x00\x01\x8aE\xfa\xa6\x82\
-\x00\x00\x08\x94\x00\x00\x00\x00\x00\x01\x00\x07?S\
+\x00\x00\x08\xac\x00\x00\x00\x00\x00\x01\x00\x07@\xe4\
\x00\x00\x01\x8aE\xfa\xa6\x82\
-\x00\x00\x06>\x00\x00\x00\x00\x00\x01\x00\x06\xedW\
+\x00\x00\x06V\x00\x00\x00\x00\x00\x01\x00\x06\xee\xe8\
\x00\x00\x01\x8aE\xfa\xa6`\
-\x00\x00\x09d\x00\x00\x00\x00\x00\x01\x00\x07r\xb2\
+\x00\x00\x09\xa6\x00\x00\x00\x00\x00\x01\x00\x07vV\
\x00\x00\x01\x8aE\xfa\xa6`\
-\x00\x00\x07^\x00\x00\x00\x00\x00\x01\x00\x07\x19\xa3\
+\x00\x00\x07v\x00\x00\x00\x00\x00\x01\x00\x07\x1b4\
\x00\x00\x01\x8aE\xfa\xa6`\
-\x00\x00\x0a\x86\x00\x00\x00\x00\x00\x01\x00\x07\x97y\
+\x00\x00\x0a\xc8\x00\x00\x00\x00\x00\x01\x00\x07\x9b\x1d\
\x00\x00\x01\x8aE\xfa\xa6q\
-\x00\x00\x06\x02\x00\x00\x00\x00\x00\x01\x00\x06\xe8\xdf\
+\x00\x00\x06\x1a\x00\x00\x00\x00\x00\x01\x00\x06\xeap\
\x00\x00\x01\x8aE\xfa\xa6\x82\
-\x00\x00\x06\x98\x00\x00\x00\x00\x00\x01\x00\x06\xf5g\
+\x00\x00\x06\xb0\x00\x00\x00\x00\x00\x01\x00\x06\xf6\xf8\
\x00\x00\x01\x8aE\xfa\xa6`\
-\x00\x00\x07\xfa\x00\x00\x00\x00\x00\x01\x00\x07!x\
+\x00\x00\x08\x12\x00\x00\x00\x00\x00\x01\x00\x07#\x09\
\x00\x00\x01\x8aE\xfa\xa6`\
-\x00\x00\x06\xfe\x00\x00\x00\x00\x00\x01\x00\x07\x13\x93\
+\x00\x00\x07\x16\x00\x00\x00\x00\x00\x01\x00\x07\x15$\
\x00\x00\x01\x8aE\xfa\xa6`\
-\x00\x00\x0a\xd0\x00\x00\x00\x00\x00\x01\x00\x07\x9b;\
+\x00\x00\x0b\x12\x00\x00\x00\x00\x00\x01\x00\x07\x9e\xdf\
\x00\x00\x01\x8aE\xfa\xa6q\
-\x00\x00\x0aV\x00\x00\x00\x00\x00\x01\x00\x07\x96t\
+\x00\x00\x0a\x98\x00\x00\x00\x00\x00\x01\x00\x07\x9a\x18\
\x00\x00\x01\x8aE\xfa\xa6\x81\
-\x00\x00\x05\xe6\x00\x00\x00\x00\x00\x01\x00\x06\xe5\xba\
+\x00\x00\x05\xfe\x00\x00\x00\x00\x00\x01\x00\x06\xe7K\
\x00\x00\x01\x8aE\xfa\xa6q\
-\x00\x00\x07\x80\x00\x00\x00\x00\x00\x01\x00\x07\x1a\xd0\
+\x00\x00\x07\x98\x00\x00\x00\x00\x00\x01\x00\x07\x1ca\
\x00\x00\x01\x8aE\xfa\xa6`\
-\x00\x00\x09N\x00\x00\x00\x00\x00\x01\x00\x07p\xff\
+\x00\x00\x09\x90\x00\x00\x00\x00\x00\x01\x00\x07t\xa3\
\x00\x00\x01\x8aE\xfa\xa6`\
-\x00\x00\x0a\xf8\x00\x00\x00\x00\x00\x01\x00\x07\x9d\x96\
+\x00\x00\x0b:\x00\x00\x00\x00\x00\x01\x00\x07\xa1:\
\x00\x00\x01\x8aE\xfa\xa6`\
-\x00\x00\x09\xb8\x00\x00\x00\x00\x00\x01\x00\x07\x82E\
+\x00\x00\x09\xfa\x00\x00\x00\x00\x00\x01\x00\x07\x85\xe9\
\x00\x00\x01\x8aE\xfa\xa6\x82\
-\x00\x00\x08\xda\x00\x00\x00\x00\x00\x01\x00\x07S\x8c\
+\x00\x00\x09\x1c\x00\x00\x00\x00\x00\x01\x00\x07W0\
\x00\x00\x01\x8aE\xfa\xa6\x82\
-\x00\x00\x098\x00\x00\x00\x00\x00\x01\x00\x07d\xdb\
+\x00\x00\x09z\x00\x00\x00\x00\x00\x01\x00\x07h\x7f\
\x00\x00\x01\x8aE\xfa\xa6\x82\
-\x00\x00\x06\x84\x00\x00\x00\x00\x00\x01\x00\x06\xf0\xcd\
+\x00\x00\x06\x9c\x00\x00\x00\x00\x00\x01\x00\x06\xf2^\
\x00\x00\x01\x8aE\xfa\xa6`\
-\x00\x00\x09\xe4\x00\x00\x00\x00\x00\x01\x00\x07\x85\x07\
+\x00\x00\x0a&\x00\x00\x00\x00\x00\x01\x00\x07\x88\xab\
\x00\x00\x01\x8aE\xfa\xa6q\
-\x00\x00\x09\x88\x00\x00\x00\x00\x00\x01\x00\x07\x80\xaf\
+\x00\x00\x09\xca\x00\x00\x00\x00\x00\x01\x00\x07\x84S\
\x00\x00\x01\x8aE\xfa\xa6q\
-\x00\x00\x070\x00\x00\x00\x00\x00\x01\x00\x07\x15a\
+\x00\x00\x07H\x00\x00\x00\x00\x00\x01\x00\x07\x16\xf2\
\x00\x00\x01\x8aE\xfa\xa6`\
-\x00\x00\x09\xfc\x00\x00\x00\x00\x00\x01\x00\x07\x87Q\
+\x00\x00\x0a>\x00\x00\x00\x00\x00\x01\x00\x07\x8a\xf5\
\x00\x00\x01\x8aE\xfa\xa6`\
-\x00\x00\x06n\x00\x00\x00\x00\x00\x01\x00\x06\xef#\
+\x00\x00\x06\x86\x00\x00\x00\x00\x00\x01\x00\x06\xf0\xb4\
\x00\x00\x01\x8aE\xfa\xa6\x82\
-\x00\x00\x0a\x14\x00\x00\x00\x00\x00\x01\x00\x07\x88\xb7\
+\x00\x00\x0aV\x00\x00\x00\x00\x00\x01\x00\x07\x8c[\
\x00\x00\x01\x8aE\xfa\xa6`\
-\x00\x00\x07\xcc\x00\x01\x00\x00\x00\x01\x00\x07\x1f\x00\
+\x00\x00\x07\xe4\x00\x01\x00\x00\x00\x01\x00\x07 \x91\
\x00\x00\x01\x8aE\xfa\xa6\x82\
-\x00\x00\x07\xe6\x00\x00\x00\x00\x00\x01\x00\x07\x1f\xda\
+\x00\x00\x07\xfe\x00\x00\x00\x00\x00\x01\x00\x07!k\
\x00\x00\x01\x8aE\xfa\xa6q\
-\x00\x00\x0a\xaa\x00\x00\x00\x00\x00\x01\x00\x07\x99\x5c\
+\x00\x00\x0a\xec\x00\x00\x00\x00\x00\x01\x00\x07\x9d\x00\
\x00\x00\x01\x8aE\xfa\xa6p\
-\x00\x00\x04\xd8\x00\x00\x00\x00\x00\x01\x00\x07\x06\xaf\
+\x00\x00\x04\xf0\x00\x00\x00\x00\x00\x01\x00\x07\x08@\
\x00\x00\x01\x8aE\xfa\xa6q\
-\x00\x00\x05@\x00\x00\x00\x00\x00\x01\x00\x071X\
+\x00\x00\x05X\x00\x00\x00\x00\x00\x01\x00\x072\xe9\
\x00\x00\x01\x8aE\xfa\xa6`\
-\x00\x00\x05l\x00\x00\x00\x00\x00\x01\x00\x07f/\
+\x00\x00\x05\x84\x00\x00\x00\x00\x00\x01\x00\x07i\xd3\
\x00\x00\x01\x8aE\xfa\xa6`\
-\x00\x00\x05\x98\x00\x00\x00\x00\x00\x01\x00\x06\xe1\xa9\
+\x00\x00\x05\xb0\x00\x00\x00\x00\x00\x01\x00\x06\xe3:\
\x00\x00\x01\x8aE\xfa\xa6\x92\
-\x00\x00\x04\xf6\x00\x00\x00\x00\x00\x01\x00\x06\xc90\
+\x00\x00\x05\x0e\x00\x00\x00\x00\x00\x01\x00\x06\xca\xc1\
\x00\x00\x01\x8aE\xfa\xa6\x92\
-\x00\x00\x02\x80\x00\x00\x00\x00\x00\x01\x00\x06\xc5\x1e\
+\x00\x00\x02\x80\x00\x00\x00\x00\x00\x01\x00\x06\xc6\xaf\
\x00\x00\x01\x8aE\xfa\xa6\x92\
-\x00\x00\x04\xb6\x00\x00\x00\x00\x00\x01\x00\x06\xc3\x0b\
+\x00\x00\x04\xce\x00\x00\x00\x00\x00\x01\x00\x06\xc4\x9c\
\x00\x00\x01\x8aE\xfa\xa6\x92\
-\x00\x00\x05\xca\x00\x00\x00\x00\x00\x01\x00\x06\xe3{\
+\x00\x00\x05\xe2\x00\x00\x00\x00\x00\x01\x00\x06\xe5\x0c\
\x00\x00\x01\x8aE\xfa\xa6\x92\
-\x00\x00\x05\x10\x00\x00\x00\x00\x00\x01\x00\x06\xcb\xba\
+\x00\x00\x05(\x00\x00\x00\x00\x00\x01\x00\x06\xcdK\
\x00\x00\x01\x8aE\xfa\xa6\x92\
-\x00\x00\x04\xd8\x00\x00\x00\x00\x00\x01\x00\x06\xc6\xff\
+\x00\x00\x04\xf0\x00\x00\x00\x00\x00\x01\x00\x06\xc8\x90\
\x00\x00\x01\x8aE\xfa\xa6\x92\
-\x00\x00\x05@\x00\x00\x00\x00\x00\x01\x00\x06\xcem\
+\x00\x00\x05X\x00\x00\x00\x00\x00\x01\x00\x06\xcf\xfe\
\x00\x00\x01\x8aE\xfa\xa6\x92\
-\x00\x00\x05l\x00\x00\x00\x00\x00\x01\x00\x06\xd8'\
+\x00\x00\x05\x84\x00\x00\x00\x00\x00\x01\x00\x06\xd9\xb8\
\x00\x00\x01\x8aE\xfa\xa6\x92\
\x00\x00\x01&\x00\x01\x00\x00\x00\x01\x00\x03\x04\x82\
\x00\x00\x01\x8aE\xfa\xa6P\
diff --git a/spinetoolbox/resources_logos_rc.py b/spinetoolbox/resources_logos_rc.py
index a72f0e616..a4342862e 100644
--- a/spinetoolbox/resources_logos_rc.py
+++ b/spinetoolbox/resources_logos_rc.py
@@ -1,6 +1,7 @@
# Resource object code (Python 3)
######################################################################################################################
# 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)
@@ -8709,15 +8710,15 @@
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x05\x00\x00\x00\x02\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x9a\x00\x00\x00\x00\x00\x01\x00\x00\x94\x9a\
-\x00\x00\x01y\x13P\x1f\xc5\
+\x00\x00\x01\x8aE\xfa\xa6?\
\x00\x00\x00p\x00\x00\x00\x00\x00\x01\x00\x00\x86\x0d\
-\x00\x00\x01y\x13P\x1f\xc4\
+\x00\x00\x01\x8aE\xfa\xa6/\
\x00\x00\x00 \x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
-\x00\x00\x01y\x13P\x1f\xc3\
+\x00\x00\x01\x8aE\xfa\xa6/\
\x00\x00\x00\xc6\x00\x00\x00\x00\x00\x01\x00\x00\xd6\x82\
-\x00\x00\x01y#!\xbfm\
+\x00\x00\x01\x8aE\xfa\xa6?\
\x00\x00\x00R\x00\x00\x00\x00\x00\x01\x00\x00E\xca\
-\x00\x00\x01y\x13P\x1f\xc3\
+\x00\x00\x01\x8aE\xfa\xa6/\
"
def qInitResources():
diff --git a/spinetoolbox/server/__init__.py b/spinetoolbox/server/__init__.py
index 23c78291f..5d2780281 100644
--- a/spinetoolbox/server/__init__.py
+++ b/spinetoolbox/server/__init__.py
@@ -1,5 +1,6 @@
######################################################################################################################
# Copyright (C) 2017-2022 Spine project consortium
+# Copyright Spine Toolbox contributors
# This file is part of Spine Toolbox.
# Spine Engine 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
@@ -9,6 +10,4 @@
# this program. If not, see .
######################################################################################################################
-"""
-Package for handling the client part of executing projects on Spine Engine Server.
-"""
+"""Package for handling the client part of executing projects on Spine Engine Server."""
diff --git a/spinetoolbox/server/engine_client.py b/spinetoolbox/server/engine_client.py
index fab6583db..018868f35 100644
--- a/spinetoolbox/server/engine_client.py
+++ b/spinetoolbox/server/engine_client.py
@@ -1,5 +1,6 @@
######################################################################################################################
# Copyright (C) 2017-2022 Spine project consortium
+# Copyright Spine Toolbox contributors
# This file is part of Spine Toolbox.
# Spine Engine 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Client for exchanging messages between the toolbox and the Spine Engine Server.
-"""
-
+"""Client for exchanging messages between the toolbox and the Spine Engine Server."""
import os
import time
import random
@@ -56,9 +54,9 @@ def __init__(self, host, port, sec_model, sec_folder, ping=True):
# implementation below based on https://github.com/zeromq/pyzmq/blob/main/examples/security/stonehouse.py
# prepare folders
base_dir = sec_folder
- secret_keys_dir = os.path.join(base_dir, 'private_keys')
- keys_dir = os.path.join(base_dir, 'certificates')
- public_keys_dir = os.path.join(base_dir, 'public_keys')
+ secret_keys_dir = os.path.join(base_dir, "private_keys")
+ keys_dir = os.path.join(base_dir, "certificates")
+ public_keys_dir = os.path.join(base_dir, "public_keys")
# We need two certificates, one for the client and one for
# the server. The client must know the server's public key
# to make a CURVE connection.
@@ -190,15 +188,15 @@ def stop_execution(self, job_id):
response_server_message = ServerMessage.parse(response[1])
return response_server_message.getData()
- def answer_prompt(self, job_id, item_name, accepted):
+ def answer_prompt(self, job_id, prompter_id, answer):
"""Sends a request to answer a prompt from the DAG that is managed by this client.
Args:
job_id (str): Job Id on server to stop
- item_name (str)
- accepted (Bool)
+ prompter_id (int)
+ answer
"""
- req = ServerMessage("answer_prompt", job_id, json.dumps((item_name, True)), None)
+ req = ServerMessage("answer_prompt", job_id, json.dumps((prompter_id, answer)), None)
self.socket.send_multipart([req.to_bytes()])
def download_files(self, q):
diff --git a/spinetoolbox/spine_db_commands.py b/spinetoolbox/spine_db_commands.py
index 7a9eacbd7..014002645 100644
--- a/spinetoolbox/spine_db_commands.py
+++ b/spinetoolbox/spine_db_commands.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,22 +10,12 @@
# this program. If not, see .
######################################################################################################################
-"""
-QUndoCommand subclasses for modifying the db.
-"""
-
+"""QUndoCommand subclasses for modifying the db."""
import time
-import uuid
-from collections import defaultdict
-
from PySide6.QtGui import QUndoCommand, QUndoStack
class AgedUndoStack(QUndoStack):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self._command_backup = []
-
@property
def redo_age(self):
if self.canRedo():
@@ -37,79 +28,47 @@ def undo_age(self):
return self.command(self.index() - 1).age
return -1
- def commands(self):
- return [self.command(i) for i in range(self.index())]
-
- def push(self, cmd):
- if self.cleanIndex() > self.index() and not self._command_backup:
- # Pushing the command will delete all undone commands.
- # We need to save all commands till the clean index.
- self._command_backup = [self.command(i).clone() for i in range(self.cleanIndex())]
- super().push(cmd)
-
- def commit(self):
- if self._command_backup:
- # Find index where backup and stack branch away
- branching_idx = next(
- (i for i in range(self.index()) if not self._command_backup[i].is_clone(self.command(i))), None
- )
- # Undo all backup commands from last till branching index
- for cmd in reversed(self._command_backup[branching_idx:]):
- cmd.undo()
- # Redo all commands from branching index till stack index
- for i in range(branching_idx, self.index()):
- self.command(i).redo()
- return
- if self.index() > self.cleanIndex():
- for i in range(self.cleanIndex(), self.index()):
- self.command(i).redo()
- elif self.index() < self.cleanIndex():
- for i in reversed(range(self.index(), self.cleanIndex())):
- self.command(i).undo()
-
- def setClean(self):
- self._command_backup = []
- super().setClean()
-
class AgedUndoCommand(QUndoCommand):
- def __init__(self, parent=None):
+ def __init__(self, parent=None, identifier=-1):
"""
Args:
parent (QUndoCommand, optional): The parent command, used for defining macros.
"""
super().__init__(parent=parent)
self._age = -1
- self.identifier = uuid.uuid4()
-
- def clone(self):
- """Clones the command.
+ self._id = identifier
+ self._buddies = []
+ self.merged = False
- Returns:
- AgedUndoCommand: cloned command
- """
- clone = self._do_clone()
- clone.identifier = self.identifier
- return clone
-
- def _do_clone(self):
- """Clones the command.
+ def id(self):
+ """override"""
+ return self._id
- Subclasses should reimplement this to clone their internal state.
+ def ours(self):
+ yield self
+ yield from self._buddies
- Returns:
- AgedUndoCommand: cloned command
- """
- raise NotImplementedError()
-
- def is_clone(self, other):
- return other.identifier == self.identifier
+ def mergeWith(self, command):
+ if not isinstance(command, AgedUndoCommand):
+ return False
+ self._buddies += [x for x in command.ours() if not x.isObsolete()]
+ command.merged = True
+ return True
def redo(self):
+ if self.merged:
+ return
super().redo()
+ for cmd in self._buddies:
+ cmd.redo()
self._age = time.time()
def undo(self):
+ if self.merged:
+ return
+ for cmd in reversed(self._buddies):
+ cmd.undo()
super().undo()
self._age = time.time()
@@ -118,309 +77,164 @@ def age(self):
return self._age
-class SpineDBMacro(AgedUndoCommand):
- """A command that just runs a series of SpineDBCommand's one after another, *waiting* for each one to finish
- before starting the next."""
-
- def __init__(self, cmd_iter, parent=None):
- super().__init__(parent=parent)
- self._cmd_iter = cmd_iter
- self._reverse_cmd_iter = None
- self._cmds = []
- self._completed_once = False
-
- def _do_clone(self):
- clone = SpineDBMacro([])
- clone._cmd_iter = self._cmd_iter
- clone._reverse_cmd_iter = self._reverse_cmd_iter
- clone._cmds = self._cmds
- clone._completed_once = self._completed_once
- return clone
-
- def redo(self):
- super().redo()
- if self._completed_once:
- self._cmd_iter = iter(self._cmds)
- self._redo_next()
-
- def _redo_next(self):
- child = next(self._cmd_iter, None)
- if child is None:
- self._completed_once = True
- return
- if not self._completed_once:
- self._cmds.append(child)
- child.redo_complete_callback = self._redo_next
- child.redo()
-
- def undo(self):
- super().undo()
- self._reverse_cmd_iter = reversed(self._cmds)
- self._undo_next()
-
- def _undo_next(self):
- child = next(self._reverse_cmd_iter, None)
- if child is None:
- return
- child.undo_complete_callback = self._undo_next
- child.undo()
-
-
class SpineDBCommand(AgedUndoCommand):
"""Base class for all commands that modify a Spine DB."""
- def __init__(self, db_mngr, db_map, parent=None):
+ def __init__(self, db_mngr, db_map, **kwargs):
"""
Args:
db_mngr (SpineDBManager): SpineDBManager instance
db_map (DiffDatabaseMapping): DiffDatabaseMapping instance
- parent (QUndoCommand, optional): The parent command, used for defining macros.
"""
- super().__init__(parent=parent)
+ super().__init__(**kwargs)
self.db_mngr = db_mngr
self.db_map = db_map
- self._done_once = False
- self.redo_complete_callback = lambda: None
- self.undo_complete_callback = lambda: None
-
- def handle_undo_complete(self, db_map_data):
- """Calls the undo complete callback with the data from undo().
-
- Subclasses need to pass this as the callback to the function that modifies the db in undo().
-
- Args:
- db_map_data (dict): mapping from database map to list of original cache items
- """
- self.undo_complete_callback()
-
- def handle_redo_complete(self, db_map_data):
- """Calls the redo complete callback with the data from redo().
-
- Subclasses need to pass this as the callback to the function that modifies the db in redo().
-
- Args:
- db_map_data (dict): mapping from database map to list of cache items
- """
- self.redo_complete_callback()
- if self._done_once:
- return
- self._done_once = True
- self._handle_first_redo_complete(db_map_data)
-
- def _handle_first_redo_complete(self, db_map_data):
- """Reimplement in subclasses to do stuff with the data from running redo() the first time.
-
- Args:
- db_map_data (dict): mapping from database map to list of original cache items
- """
- raise NotImplementedError()
class AddItemsCommand(SpineDBCommand):
- _add_command_name = {
- "object_class": "add object classes",
- "object": "add objects",
- "relationship_class": "add relationship classes",
- "relationship": "add relationships",
- "entity_group": "add entity groups",
- "parameter_definition": "add parameter definitions",
- "parameter_value": "add parameter values",
- "parameter_value_list": "add parameter value lists",
- "list_value": "add parameter value list values",
- "alternative": "add alternative",
- "scenario": "add scenario",
- "scenario_alternative": "add scenario alternative",
- "feature": "add feature",
- "tool": "add tool",
- "tool_feature": "add tool features",
- "tool_feature_method": "add tool feature methods",
- "metadata": "add metadata",
- "entity_metadata": "add entity metadata",
- "parameter_value_metadata": "add parameter value metadata",
- }
-
- def __init__(self, db_mngr, db_map, data, item_type, parent=None, check=True):
+ def __init__(self, db_mngr, db_map, item_type, data, check=True, **kwargs):
"""
Args:
db_mngr (SpineDBManager): SpineDBManager instance
db_map (DiffDatabaseMapping): DiffDatabaseMapping instance
data (list): list of dict-items to add
item_type (str): the item type
- parent (QUndoCommand, optional): The parent command, used for defining macros.
"""
- super().__init__(db_mngr, db_map, parent=parent)
+ super().__init__(db_mngr, db_map, **kwargs)
if not data:
self.setObsolete(True)
- self.redo_db_map_data = {db_map: data}
self.item_type = item_type
- self.undo_db_map_ids = None
- self._readd = False
+ self.redo_data = data
+ self.undo_ids = None
self._check = check
- self.setText(self._add_command_name.get(item_type, "add item") + f" to '{db_map.codename}'")
-
- def _do_clone(self):
- clone = AddItemsCommand(self.db_mngr, self.db_map, [], self.item_type)
- clone.redo_db_map_data = self.redo_db_map_data
- clone.undo_db_map_ids = self.undo_db_map_ids
- clone._readd = self._readd
- clone._check = self._check
- return clone
+ self.setText(f"add {item_type} items to {db_map.codename}")
def redo(self):
super().redo()
- successful = self.db_mngr.add_items(
- self.redo_db_map_data,
- self.item_type,
- readd=self._readd,
- cascade=False,
- check=self._check,
- callback=self.handle_redo_complete,
- )
- if not successful:
+ if self.undo_ids:
+ self.db_mngr.do_restore_items(self.db_map, self.item_type, self.undo_ids)
+ return
+ data = self.db_mngr.do_add_items(self.db_map, self.item_type, self.redo_data, check=self._check)
+ if not data:
self.setObsolete(True)
+ return
+ self.undo_ids = {x["id"] for x in data}
def undo(self):
super().undo()
- self.db_mngr.do_remove_items(self.item_type, self.undo_db_map_ids, callback=self.handle_undo_complete)
- self._readd = True
-
- def _handle_first_redo_complete(self, db_map_data):
- if self.db_map not in db_map_data:
- self.setObsolete(True)
- return
- self.redo_db_map_data = {db_map: [x.deepcopy() for x in data] for db_map, data in db_map_data.items()}
- self.undo_db_map_ids = {db_map: {x["id"] for x in data} for db_map, data in db_map_data.items()}
+ self.db_mngr.do_remove_items(self.db_map, self.item_type, self.undo_ids, check=False)
class UpdateItemsCommand(SpineDBCommand):
- _update_command_name = {
- "object_class": "update object classes",
- "object": "update objects",
- "relationship_class": "update relationship classes",
- "relationship": "update relationships",
- "parameter_definition": "update parameter definitions",
- "parameter_value": "update parameter values",
- "parameter_value_list": "update parameter value lists",
- "list_value": "update parameter value list values",
- "alternative": "update alternatives",
- "scenario": "update scenarios",
- "scenario_alternative": "update scenario alternative",
- "feature": "update features",
- "tool": "update tools",
- "tool_feature": "update tool features",
- "tool_feature_method": "update tool feature methods",
- "metadata": "update metadata",
- "entity_metadata": "update entity metadata",
- "parameter_value_metadata": "update parameter value metadata",
- }
-
- def __init__(self, db_mngr, db_map, data, item_type, parent=None, check=True):
+ def __init__(self, db_mngr, db_map, item_type, data, check=True, **kwargs):
"""
Args:
db_mngr (SpineDBManager): SpineDBManager instance
db_map (DiffDatabaseMapping): DiffDatabaseMapping instance
- data (list): list of dict-items to update
item_type (str): the item type
- parent (QUndoCommand, optional): The parent command, used for defining macros.
+ data (list): list of dict-items to update
"""
- super().__init__(db_mngr, db_map, parent=parent)
+ super().__init__(db_mngr, db_map, **kwargs)
if not data:
self.setObsolete(True)
- undo_data = [self.db_mngr.get_item(self.db_map, item_type, item["id"]).copy() for item in data]
- redo_data = [{**undo_item, **item} for undo_item, item in zip(undo_data, data)]
- if undo_data == redo_data:
- self.setObsolete(True)
- self.redo_db_map_data = {db_map: redo_data}
- self.undo_db_map_data = {db_map: undo_data}
self.item_type = item_type
+ self.redo_data = data
+ self.undo_data = [self.db_mngr.get_item(self.db_map, item_type, item["id"])._asdict() for item in data]
+ if self.redo_data == self.undo_data:
+ self.setObsolete(True)
self._check = check
- self.setText(self._update_command_name.get(item_type, "update item") + f" in '{db_map.codename}'")
-
- def _do_clone(self):
- clone = UpdateItemsCommand(self.db_mngr, self.db_map, [], self.item_type)
- clone.redo_db_map_data = self.redo_db_map_data
- clone.undo_db_map_data = self.undo_db_map_data
- clone._check = self._check
- return clone
+ self.setText(f"update {item_type} items in {db_map.codename}")
def redo(self):
super().redo()
- successful = self.db_mngr.update_items(
- self.redo_db_map_data, self.item_type, check=self._check, callback=self.handle_redo_complete
- )
- if not successful:
+ self.redo_data = [
+ x._asdict()
+ for x in self.db_mngr.do_update_items(self.db_map, self.item_type, self.redo_data, check=self._check)
+ ]
+ if not self.redo_data:
self.setObsolete(True)
+ return
+ self._check = False
def undo(self):
super().undo()
- self.db_mngr.update_items(
- self.undo_db_map_data, self.item_type, check=False, callback=self.handle_undo_complete
- )
+ self.db_mngr.do_update_items(self.db_map, self.item_type, self.undo_data, check=False)
+
- def _handle_first_redo_complete(self, db_map_data):
- if self.db_map not in db_map_data:
+class AddUpdateItemsCommand(SpineDBCommand):
+ def __init__(self, db_mngr, db_map, item_type, data, check=True, **kwargs):
+ """
+ Args:
+ db_mngr (SpineDBManager): SpineDBManager instance
+ db_map (DiffDatabaseMapping): DiffDatabaseMapping instance
+ item_type (str): the item type
+ data (list): list of dict-items to add-update
+ """
+ super().__init__(db_mngr, db_map, **kwargs)
+ if not data:
+ self.setObsolete(True)
+ self.item_type = item_type
+ self.new_data = data
+ old_data = [x._asdict() for item in data if (x := self.db_map.get_item(item_type, **item))]
+ if self.new_data == old_data:
self.setObsolete(True)
+ self.old_data = {x["id"]: x for x in old_data}
+ self.redo_restore_ids = None
+ 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}")
+
+ def redo(self):
+ super().redo()
+ if self.redo_restore_ids is None:
+ added, updated = self.db_mngr.do_add_update_items(self.db_map, self.item_type, self.new_data)
+ if not added and not updated:
+ self.setObsolete(True)
+ return
+ self.redo_restore_ids = {x["id"] for x in added}
+ self.redo_update_data = [x._asdict() for x in updated]
+ self.undo_remove_ids = {x["id"] for x in added}
+ self.undo_update_data = [self.old_data[id_] for id_ in {x["id"] for x in updated}]
return
- self.redo_db_map_data = db_map_data
- self._check = False
+ if self.redo_restore_ids:
+ self.db_mngr.do_restore_items(self.db_map, self.item_type, self.redo_restore_ids)
+ if self.redo_update_data:
+ self.db_mngr.do_update_items(self.db_map, self.item_type, self.redo_update_data, check=False)
+
+ def undo(self):
+ super().undo()
+ if self.undo_remove_ids:
+ self.db_mngr.do_remove_items(self.db_map, self.item_type, self.undo_remove_ids, check=False)
+ if self.undo_update_data:
+ self.db_mngr.do_update_items(self.db_map, self.item_type, self.undo_update_data, check=False)
class RemoveItemsCommand(SpineDBCommand):
- def __init__(self, db_mngr, db_map, ids, item_type, parent=None):
+ def __init__(self, db_mngr, db_map, item_type, ids, check=True, **kwargs):
"""
Args:
db_mngr (SpineDBManager): SpineDBManager instance
db_map (DiffDatabaseMapping): DiffDatabaseMapping instance
- ids (set): set of ids to remove
item_type (str): the item type
- parent (QUndoCommand, optional): The parent command, used for defining macros.
+ ids (set): set of ids to remove
"""
- super().__init__(db_mngr, db_map, parent=parent)
+ super().__init__(db_mngr, db_map, **kwargs)
if not ids:
self.setObsolete(True)
- self.redo_db_map_ids = {db_map: ids}
- self.undo_data = {}
self.item_type = item_type
- self.setText(f"remove {item_type} items from '{db_map.codename}'")
-
- def _do_clone(self):
- clone = RemoveItemsCommand(self.db_mngr, self.db_map, set(), self.item_type)
- clone.redo_db_map_ids = self.redo_db_map_ids
- clone.undo_data = self.undo_data
- return clone
+ self.ids = ids
+ self._check = check
+ self.setText(f"remove {item_type} items from {db_map.codename}")
def redo(self):
super().redo()
- self.db_mngr.do_remove_items(
- self.item_type,
- self.redo_db_map_ids,
- callback=self.handle_redo_complete,
- committing_callback=self._update_undo_data,
- )
+ items = self.db_mngr.do_remove_items(self.db_map, self.item_type, self.ids, check=self._check)
+ if not items:
+ self.setObsolete(True)
+ self.ids = {x["id"] for x in items}
+ self._check = False
def undo(self):
super().undo()
- operations = list(self.undo_data.items())
- for item_type, items in operations[:-1]:
- self.db_mngr.add_items({self.db_map: items}, item_type, readd=True)
- item_type, items = operations[-1]
- self.db_mngr.add_items({self.db_map: items}, item_type, readd=True, callback=self.handle_undo_complete)
-
- def _handle_first_redo_complete(self, db_map_data):
- undo_data = defaultdict(list)
- for db_map, data in db_map_data.items():
- if db_map is not self.db_map:
- continue
- for item in data:
- undo_data[item.item_type].append(item)
- self.undo_data = undo_data
-
- def _update_undo_data(self, db_map_data):
- all_existing_ids = {item_type: {item["id"] for item in items} for item_type, items in self.undo_data.items()}
- for db_map, data in db_map_data.items():
- if db_map is not self.db_map:
- continue
- for item in data:
- existing_ids = all_existing_ids.get(item.item_type)
- if existing_ids is None or item["id"] not in existing_ids:
- self.undo_data[item.item_type].append(item)
+ self.db_mngr.do_restore_items(self.db_map, self.item_type, self.ids)
diff --git a/spinetoolbox/spine_db_editor/__init__.py b/spinetoolbox/spine_db_editor/__init__.py
index 5468070d5..bf113c065 100644
--- a/spinetoolbox/spine_db_editor/__init__.py
+++ b/spinetoolbox/spine_db_editor/__init__.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,6 +10,4 @@
# this program. If not, see .
######################################################################################################################
-"""
-This subpackage contains GUI files for the Spine db editor.
-"""
+"""This subpackage contains GUI files for the Spine db editor."""
diff --git a/spinetoolbox/spine_db_editor/graphics_items.py b/spinetoolbox/spine_db_editor/graphics_items.py
index 5ee5dc29c..9e309ce7c 100644
--- a/spinetoolbox/spine_db_editor/graphics_items.py
+++ b/spinetoolbox/spine_db_editor/graphics_items.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,9 @@
# this program. If not, see .
######################################################################################################################
-"""
-Classes for drawing graphics items on graph view's QGraphicsScene.
-"""
-from PySide6.QtCore import Qt, Signal, Slot, QLineF
+"""Classes for drawing graphics items on graph view's QGraphicsScene."""
+from enum import Enum, auto
+from PySide6.QtCore import Qt, Signal, Slot, QLineF, QRectF, QPointF, QObject, QByteArray
from PySide6.QtSvgWidgets import QGraphicsSvgItem
from PySide6.QtWidgets import (
QGraphicsItem,
@@ -24,45 +24,14 @@
QApplication,
QMenu,
)
-from PySide6.QtGui import QPen, QBrush, QPainterPath, QPalette, QGuiApplication, QAction
-from matplotlib.figure import Figure
-from matplotlib.backends.backend_qt5agg import FigureCanvas # pylint: disable=no-name-in-module
-
-from spinetoolbox.helpers import DB_ITEM_SEPARATOR
+from PySide6.QtSvg import QSvgRenderer
+from PySide6.QtGui import QPen, QBrush, QPainterPath, QPalette, QGuiApplication, QAction, QColor
+from spinetoolbox.helpers import DB_ITEM_SEPARATOR, color_from_index
from spinetoolbox.widgets.custom_qwidgets import TitleWidgetAction
-def make_figure_graphics_item(scene, z=0, static=True):
- """Creates a FigureCanvas and adds it to the given scene.
- Used for creating heatmaps and associated colorbars.
-
- Args:
- scene (QGraphicsScene)
- z (int, optional): z value. Defaults to 0.
- static (bool, optional): if True (the default) the figure canvas is not movable
-
- Returns:
- QGraphicsProxyWidget: the graphics item that represents the canvas
- Figure: the figure in the canvas
- """
- figure = Figure(tight_layout={"pad": 0})
- axes = figure.gca(xmargin=0, ymargin=0, frame_on=None)
- axes.get_xaxis().set_visible(False)
- axes.get_yaxis().set_visible(False)
- canvas = FigureCanvas(figure)
- if static:
- proxy_widget = scene.addWidget(canvas)
- proxy_widget.setAcceptedMouseButtons(Qt.NoButton)
- else:
- proxy_widget = scene.addWidget(canvas, Qt.Window)
- proxy_widget.setZValue(z)
- return proxy_widget, figure
-
-
class EntityItem(QGraphicsRectItem):
- """Base class for ObjectItem and RelationshipItem."""
-
- def __init__(self, spine_db_editor, x, y, extent, db_map_ids):
+ def __init__(self, spine_db_editor, x, y, extent, db_map_ids, offset=None):
"""
Args:
spine_db_editor (SpineDBEditor): 'owner'
@@ -74,22 +43,23 @@ def __init__(self, spine_db_editor, x, y, extent, db_map_ids):
super().__init__()
self._spine_db_editor = spine_db_editor
self.db_mngr = spine_db_editor.db_mngr
+ self._given_extent = extent
self._db_map_ids = db_map_ids
+ self._offset = offset
+ self._dx = self._dy = 0
self._removed_db_map_ids = ()
- self.arc_items = list()
- self._extent = extent
- self.setRect(-0.5 * self._extent, -0.5 * self._extent, self._extent, self._extent)
+ self.arc_items = []
+ self._circle_item = QGraphicsEllipseItem(self)
+ self._circle_item.setPen(Qt.NoPen)
+ self.set_pos(x, y)
self.setPen(Qt.NoPen)
self._svg_item = QGraphicsSvgItem(self)
self._svg_item.setZValue(100)
self._svg_item.setCacheMode(QGraphicsItem.CacheMode.NoCache) # Needed for the exported pdf to be vector
self._renderer = None
- self.refresh_icon()
- self.setPos(x, y)
self._moved_on_scene = False
self._bg = None
- self._bg_brush = Qt.NoBrush
- self._init_bg()
+ self._bg_brush = None
self.setZValue(0)
self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=True)
self.setFlag(QGraphicsItem.ItemIsMovable, enabled=True)
@@ -98,17 +68,27 @@ def __init__(self, spine_db_editor, x, y, extent, db_map_ids):
self.setAcceptHoverEvents(True)
self.setCursor(Qt.ArrowCursor)
self.setToolTip(self._make_tool_tip())
- self._highligh_color = Qt.transparent
-
- def _make_tool_tip(self):
- raise NotImplementedError()
-
- def default_parameter_data(self):
- raise NotImplementedError()
+ self._highlight_color = Qt.transparent
+ self._db_map_entity_class_lists = {}
+ self.label_item = EntityLabelItem(self)
+ self.label_item.setVisible(not self.has_dimensions)
+ self.setZValue(0.5 if not self.has_dimensions else 0.25)
+ self._extent = None
+ self.set_up()
+
+ def clone(self):
+ return type(self)(
+ self._spine_db_editor,
+ self.pos().x(),
+ self.pos().y(),
+ self._given_extent,
+ self._db_map_ids,
+ offset=self._offset,
+ )
@property
- def entity_type(self):
- raise NotImplementedError()
+ def has_dimensions(self):
+ return bool(self.element_id_list(self.first_db_map))
@property
def db_map_ids(self):
@@ -119,23 +99,46 @@ def original_db_map_ids(self):
return self._db_map_ids
@property
- def entity_class_type(self):
- return {"relationship": "relationship_class", "object": "object_class"}[self.entity_type]
-
- @property
- def entity_name(self):
- return self.db_mngr.get_item(self.first_db_map, self.entity_type, self.first_id).get("name", "")
+ def name(self):
+ return self.db_mngr.get_item(self.first_db_map, "entity", self.first_id).get("name", "")
@property
def first_entity_class_id(self):
- return self.db_mngr.get_item(self.first_db_map, self.entity_type, self.first_id).get("class_id")
+ return self.db_mngr.get_item(self.first_db_map, "entity", self.first_id).get("class_id")
@property
def entity_class_name(self):
- return self.db_mngr.get_item(self.first_db_map, self.entity_class_type, self.first_entity_class_id).get(
- "name", ""
+ return self.db_mngr.get_item(self.first_db_map, "entity_class", self.first_entity_class_id).get("name", "")
+
+ @property
+ def dimension_id_list(self):
+ # FIXME: is this used?
+ return self.db_mngr.get_item(self.first_db_map, "entity_class", self.first_entity_class_id).get(
+ "dimension_id_list", ()
+ )
+
+ @property
+ def dimension_name_list(self):
+ return self.db_mngr.get_item(self.first_db_map, "entity_class", self.first_entity_class_id).get(
+ "dimension_name_list", ()
)
+ @property
+ def byname(self):
+ return self.db_mngr.get_item(self.first_db_map, "entity", self.first_id).get("entity_byname", ())
+
+ @property
+ def element_name_list(self):
+ return self.db_mngr.get_item(self.first_db_map, "entity", self.first_id).get("element_name_list", ())
+
+ def element_id_list(self, db_map):
+ return self.db_mngr.get_item(db_map, "entity", self.entity_id(db_map)).get("element_id_list", ())
+
+ @property
+ def element_byname_list(self):
+ # NOTE: Needed by EditEntitiesDialog
+ return self.db_mngr.get_item(self.first_db_map, "entity", self.first_id).get("element_byname_list", ())
+
@property
def first_db_map_id(self):
return next(iter(self.db_map_ids), (None, None))
@@ -150,7 +153,7 @@ def first_db_map(self):
@property
def display_data(self):
- return self.entity_name
+ return self.name
@property
def display_database(self):
@@ -161,59 +164,225 @@ def db_maps(self):
return list(db_map for db_map, _id in self.db_map_ids)
def entity_class_id(self, db_map):
- return self.db_mngr.get_item(db_map, self.entity_type, self.entity_id(db_map)).get("class_id")
+ return self.db_mngr.get_item(db_map, "entity", self.entity_id(db_map)).get("class_id")
+
+ def entity_class_ids(self, db_map):
+ return {self.entity_class_id(db_map)} | {
+ x["superclass_id"]
+ for x in db_map.get_items("superclass_subclass", subclass_id=self.entity_class_id(db_map))
+ }
def entity_id(self, db_map):
return dict(self.db_map_ids).get(db_map)
def db_map_data(self, db_map):
- # NOTE: Needed by EditObjectsDialog and EditRelationshipsDialog
- return self.db_mngr.get_item(db_map, self.entity_type, self.entity_id(db_map))
+ # NOTE: Needed by EditEntitiesDialog
+ return self.db_mngr.get_item(db_map, "entity", self.entity_id(db_map))
def db_map_id(self, db_map):
- # NOTE: Needed by EditObjectsDialog and EditRelationshipsDialog
+ # NOTE: Needed by EditEntitiesDialog
return self.entity_id(db_map)
+ def db_items(self, db_map):
+ for db_map_, id_ in self.db_map_ids:
+ if db_map_ == db_map:
+ yield dict(class_id=self.entity_class_id(db_map), id=id_)
+
def boundingRect(self):
return super().boundingRect() | self.childrenBoundingRect()
- def moveBy(self, dx, dy):
- super().moveBy(dx, dy)
+ def set_pos(self, x, y):
+ x, y = self._snap(x, y)
+ self.setPos(x, y)
self.update_arcs_line()
- def _init_bg(self):
- self._bg = QGraphicsRectItem(self.boundingRect(), self)
- self._bg.setFlag(QGraphicsItem.ItemStacksBehindParent, enabled=True)
+ def move_by(self, dx, dy):
+ self._dx += dx
+ self._dy += dy
+ dx, dy = self._snap(self._dx, self._dy)
+ if dx == dy == 0:
+ return
+ self.moveBy(dx, dy)
+ self._dx -= dx
+ self._dy -= dy
+ self.update_arcs_line()
+ ent_items = {arc_item.ent_item for arc_item in self.arc_items}
+ for ent_item in ent_items:
+ ent_item.update_entity_pos()
+
+ def _snap(self, x, y):
+ if self._spine_db_editor.qsettings.value("appSettings/snapEntities", defaultValue="false") != "true":
+ return (x, y)
+ grid_size = self._given_extent
+ x = round(x / grid_size) * grid_size
+ y = round(y / grid_size) * grid_size
+ return (x, y)
+
+ def has_unique_key(self):
+ """Returns whether or not the item still has a single key in all the databases it represents.
+
+ Returns:
+ bool
+ """
+ db_map_ids_by_key = {}
+ for db_map_id in self.db_map_ids:
+ key = self._spine_db_editor.get_entity_key(db_map_id)
+ db_map_ids_by_key.setdefault(key, []).append(db_map_id)
+ if len(db_map_ids_by_key) == 1:
+ return True
+ first_key = next(iter(db_map_ids_by_key))
+ self._db_map_ids = tuple(db_map_ids_by_key[first_key])
+ return False
+
+ def _get_name(self):
+ for db_map, id_ in self.db_map_ids:
+ name = self._spine_db_editor.get_item_name(db_map, id_)
+ if isinstance(name, str):
+ return name
+
+ def _get_prop(self, getter, index):
+ values = {getter(db_map, id_, index) for db_map, id_ in self.db_map_ids}
+ values.discard(None)
+ if not values:
+ return None
+ values.discard(self._spine_db_editor.NOT_SPECIFIED)
+ if not values:
+ return self._spine_db_editor.NOT_SPECIFIED
+ return next(iter(values))
+
+ def _get_color(self, index=None):
+ color = self._get_prop(self._spine_db_editor.get_item_color, index)
+ if color in (None, self._spine_db_editor.NOT_SPECIFIED):
+ return color
+ min_val, val, max_val = color
+ count = max(1, max_val - min_val)
+ k = val - min_val
+ return color_from_index(k, count)
+
+ def _get_arc_width(self, index=None):
+ arc_width = self._get_prop(self._spine_db_editor.get_arc_width, index)
+ if arc_width in (None, self._spine_db_editor.NOT_SPECIFIED):
+ return arc_width
+ min_val, val, max_val = arc_width
+ range_ = max_val - min_val
+ if range_ == 0:
+ return None
+ if val > 0:
+ return val / max_val, 1
+ return val / min_val, -1
+
+ def _get_vertex_radius(self, index=None):
+ vertex_radius = self._get_prop(self._spine_db_editor.get_vertex_radius, index)
+ if vertex_radius in (None, self._spine_db_editor.NOT_SPECIFIED):
+ return None
+ min_val, val, max_val = vertex_radius
+ range_ = max_val - min_val
+ if range_ == 0:
+ return 0
+ return (val - min_val) / range_
+
+ def _has_name(self):
+ return True
+
+ def set_up(self):
+ if self._has_name():
+ name = self._get_name()
+ if not name:
+ self.label_item.hide()
+ self._extent = 0.2 * self._given_extent
+ else:
+ if not self.has_dimensions:
+ self.label_item.show()
+ self.label_item.setPlainText(name)
+ self._extent = self._given_extent
+ else:
+ self.label_item.hide()
+ self._extent = 0.5 * self._given_extent
+ else:
+ self.label_item.hide()
+ self._extent = self._given_extent
+ self.setRect(-0.5 * self._extent, -0.5 * self._extent, self._extent, self._extent)
+ self._update_bg()
+ self.refresh_icon()
+ self.update_entity_pos()
+
+ def update_props(self, index):
+ color = self._get_color(index)
+ arc_width = self._get_arc_width(index)
+ vertex_radius = self._get_vertex_radius(index)
+ self._update_renderer(color, resize=True)
+ self._update_arcs(color, arc_width)
+ self._update_circle(color, vertex_radius)
+
+ def _update_bg(self):
+ bg_rect = QRectF(-0.5 * self._extent, -0.5 * self._extent, self._extent, self._extent)
+ if self._bg is not None:
+ self._bg.setRect(bg_rect)
+ self._bg.prepareGeometryChange()
+ self._bg.update()
+ return
+ if not self.has_dimensions:
+ self._bg = QGraphicsRectItem(bg_rect, self)
+ self._bg_brush = Qt.NoBrush
+ else:
+ self._bg = QGraphicsEllipseItem(bg_rect, self)
+ self._bg_brush = QGuiApplication.palette().button()
pen = self._bg.pen()
pen.setColor(Qt.transparent)
self._bg.setPen(pen)
+ self._bg.setFlag(QGraphicsItem.ItemStacksBehindParent, enabled=True)
def refresh_icon(self):
"""Refreshes the icon."""
- renderer = self.db_mngr.entity_class_renderer(
- self.first_db_map, self.entity_class_type, self.first_entity_class_id
- )
- self._set_renderer(renderer)
-
- def _set_renderer(self, renderer):
- self._renderer = renderer
- self._svg_item.setSharedRenderer(renderer)
- size = renderer.defaultSize()
+ color = self._get_color()
+ self._update_renderer(color)
+
+ def _update_renderer(self, color, resize=True):
+ if color is self._spine_db_editor.NOT_SPECIFIED:
+ color = color_from_index(0, 1, value=0)
+ self._renderer = self.db_mngr.entity_class_renderer(self.first_db_map, self.first_entity_class_id, color=color)
+ self._install_renderer()
+
+ def _install_renderer(self, resize=True):
+ self._svg_item.setSharedRenderer(self._renderer)
+ if not resize:
+ return
+ size = self._renderer.defaultSize()
scale = self._extent / max(size.width(), size.height())
self._svg_item.setScale(scale)
rect = self._svg_item.boundingRect()
self._svg_item.setTransformOriginPoint(rect.center())
self._svg_item.setPos(-rect.center())
+ def _make_tool_tip(self):
+ if not self.first_id:
+ return None
+ return (
+ f"""
Drag and drop this item to reorder scenario alternatives
"
+ return super().tool_tip(column)
def _make_item_data(self):
return {"name": "Type scenario alternative name here...", "description": ""}
diff --git a/spinetoolbox/spine_db_editor/mvcmodels/scenario_model.py b/spinetoolbox/spine_db_editor/mvcmodels/scenario_model.py
index 1c23f0d17..aac69c7e6 100644
--- a/spinetoolbox/spine_db_editor/mvcmodels/scenario_model.py
+++ b/spinetoolbox/spine_db_editor/mvcmodels/scenario_model.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -8,10 +9,10 @@
# 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 scenario tree model."""
import pickle
-
-from PySide6.QtCore import QMimeData, Qt
+from PySide6.QtCore import QMimeData, Qt, QByteArray
from spinetoolbox.helpers import unique_name
from .tree_model_base import TreeModelBase
from .scenario_item import ScenarioDBItem, ScenarioAlternativeItem, ScenarioItem
@@ -22,13 +23,8 @@
class ScenarioModel(TreeModelBase):
"""A model to display scenarios in a tree view."""
- @staticmethod
- def _make_db_item(db_map):
- return ScenarioDBItem(db_map)
-
- @staticmethod
- def _top_children():
- return []
+ def _make_db_item(self, db_map):
+ return ScenarioDBItem(self, db_map)
def supportedDropActions(self):
return Qt.DropAction.CopyAction | Qt.DropAction.MoveAction
@@ -66,7 +62,7 @@ def mimeData(self, indexes):
db_item = item.parent_item
db_key = self.db_mngr.db_map_key(db_item.db_map)
scenario_data.setdefault(db_key, []).append(item.id)
- mime.setData(mime_types.SCENARIO_DATA, pickle.dumps(scenario_data))
+ mime.setData(mime_types.SCENARIO_DATA, QByteArray(pickle.dumps(scenario_data)))
mime.setText(two_column_as_csv(scenario_indexes))
return mime
alternative_indexes = []
@@ -84,18 +80,18 @@ def mimeData(self, indexes):
db_item = item.parent_item.parent_item
db_key = self.db_mngr.db_map_key(db_item.db_map)
alternative_data.setdefault(db_key, []).append(item.alternative_id)
- mime.setData(mime_types.ALTERNATIVE_DATA, pickle.dumps(alternative_data))
+ mime.setData(mime_types.ALTERNATIVE_DATA, QByteArray(pickle.dumps(alternative_data)))
mime.setText(two_column_as_csv(alternative_indexes))
return mime
return None
- def canDropMimeData(self, data, drop_action, row, column, parent):
+ def canDropMimeData(self, mime_data, drop_action, row, column, parent):
if drop_action & self.supportedDropActions() == 0:
return False
- if not data.hasFormat(mime_types.ALTERNATIVE_DATA):
+ if not mime_data.hasFormat(mime_types.ALTERNATIVE_DATA):
return False
try:
- payload = pickle.loads(data.data(mime_types.ALTERNATIVE_DATA))
+ payload = pickle.loads(mime_data.data(mime_types.ALTERNATIVE_DATA).data())
except pickle.UnpicklingError:
return False
if not isinstance(payload, dict):
@@ -115,12 +111,12 @@ def canDropMimeData(self, data, drop_action, row, column, parent):
db_item = self.db_item(parent_item)
if db_map != db_item.db_map:
return False
- if data.hasFormat("application/vnd.spinetoolbox.scenario-alternative"):
+ if mime_data.hasFormat("application/vnd.spinetoolbox.scenario-alternative"):
# Check that reordering only happens within the same scenario
return False
return True
- def dropMimeData(self, data, drop_action, row, column, parent):
+ def dropMimeData(self, mime_data, drop_action, row, column, parent):
# This function expects that data has be verified by canDropMimeData() already.
scenario_item = self.item_from_index(parent)
if not isinstance(scenario_item, ScenarioItem):
@@ -128,15 +124,7 @@ def dropMimeData(self, data, drop_action, row, column, parent):
# on a wrong tree item (bug in Qt or canDropMimeData()?).
# In those cases the type of scen_item is StandardTreeItem or ScenarioRootItem.
return False
- old_alternative_id_list = list(scenario_item.alternative_id_list)
- if row == -1:
- row = len(old_alternative_id_list)
- db_map_key, alternative_ids = pickle.loads(data.data(mime_types.ALTERNATIVE_DATA)).popitem()
- alternative_id_list = [id_ for id_ in old_alternative_id_list[:row] if id_ not in alternative_ids]
- alternative_id_list += alternative_ids
- alternative_id_list += [id_ for id_ in old_alternative_id_list[row:] if id_ not in alternative_ids]
- db_item = {"id": scenario_item.id, "alternative_id_list": alternative_id_list}
- self.db_mngr.set_scenario_alternatives({scenario_item.db_map: [db_item]})
+ self.paste_alternative_mime_data(mime_data, row, scenario_item)
return True
def paste_alternative_mime_data(self, mime_data, row, scenario_item):
@@ -150,16 +138,21 @@ def paste_alternative_mime_data(self, mime_data, row, scenario_item):
old_alternative_id_list = list(scenario_item.alternative_id_list)
if row == -1:
row = len(old_alternative_id_list)
- data_to_add = {}
- for db_map_key, alternative_ids in pickle.loads(mime_data.data(mime_types.ALTERNATIVE_DATA)).items():
+ new_alternative_ids = []
+ for db_map_key, alternative_names in pickle.loads(mime_data.data(mime_types.ALTERNATIVE_DATA).data()).items():
target_db_map = self.db_mngr.db_map_from_key(db_map_key)
if target_db_map != scenario_item.db_map:
continue
- alternative_id_list = [id_ for id_ in old_alternative_id_list[:row] if id_ not in alternative_ids]
- alternative_id_list += alternative_ids
- alternative_id_list += [id_ for id_ in old_alternative_id_list[row:] if id_ not in alternative_ids]
- data_to_add[target_db_map] = [{"id": scenario_item.id, "alternative_id_list": alternative_id_list}]
- self.db_mngr.set_scenario_alternatives(data_to_add)
+ for name in alternative_names:
+ if isinstance(name, str):
+ new_alternative_ids.append(scenario_item.db_map.get_alternative_item(name=name)["id"])
+ else: # When rearranging alternatives in a scenario, the id is given straight
+ new_alternative_ids.append(name)
+ alternative_id_list = [id_ for id_ in old_alternative_id_list[:row] if id_ not in new_alternative_ids]
+ alternative_id_list += new_alternative_ids
+ alternative_id_list += [id_ for id_ in old_alternative_id_list[row:] if id_ not in new_alternative_ids]
+ db_item = {"id": scenario_item.id, "alternative_id_list": alternative_id_list}
+ self.db_mngr.set_scenario_alternatives({scenario_item.db_map: [db_item]})
def paste_scenario_mime_data(self, mime_data, db_item):
"""Adds scenarios and their alternatives from MIME data to the model.
@@ -171,38 +164,36 @@ def paste_scenario_mime_data(self, mime_data, db_item):
scenarios_to_add = []
alternatives_to_add = []
alternative_names_by_scenario = {}
- existing_scenarios = {i.name for i in self.db_mngr.get_items(db_item.db_map, "scenario", only_visible=False)}
- existing_alternatives = {
- i.name for i in self.db_mngr.get_items(db_item.db_map, "alternative", only_visible=False)
- }
- for db_map_key, scenario_ids in pickle.loads(mime_data.data(mime_types.SCENARIO_DATA)).items():
+ existing_scenarios = {i["name"] for i in self.db_mngr.get_items(db_item.db_map, "scenario")}
+ existing_alternatives = {i["name"] for i in self.db_mngr.get_items(db_item.db_map, "alternative")}
+ for db_map_key, scenario_names in pickle.loads(mime_data.data(mime_types.SCENARIO_DATA).data()).items():
db_map = self.db_mngr.db_map_from_key(db_map_key)
if db_map is db_item.db_map:
continue
- for id_ in scenario_ids:
- scenario_data = self.db_mngr.get_item(db_map, "scenario", id_, only_visible=False)
- if scenario_data.name in existing_scenarios:
+ for name in scenario_names:
+ scenario_data = db_map.get_scenario_item(name=name)
+ if scenario_data["name"] in existing_scenarios:
continue
- alternative_id_list = self.db_mngr.get_scenario_alternative_id_list(db_map, id_, only_visible=False)
+ alternative_id_list = self.db_mngr.get_scenario_alternative_id_list(db_map, scenario_data["id"])
for alternative_id in alternative_id_list:
- alternative_db_item = self.db_mngr.get_item(
- db_map, "alternative", alternative_id, only_visible=False
+ alternative_db_item = self.db_mngr.get_item(db_map, "alternative", alternative_id)
+ alternative_names_by_scenario.setdefault(scenario_data["name"], []).append(
+ alternative_db_item["name"]
)
- alternative_names_by_scenario.setdefault(scenario_data.name, []).append(alternative_db_item.name)
- if alternative_db_item.name in existing_alternatives:
+ if alternative_db_item["name"] in existing_alternatives:
continue
alternatives_to_add.append(
- {"name": alternative_db_item.name, "description": alternative_db_item.description}
+ {"name": alternative_db_item["name"], "description": alternative_db_item["description"]}
)
- scenarios_to_add.append({"name": scenario_data.name, "description": scenario_data.description})
+ scenarios_to_add.append({"name": scenario_data["name"], "description": scenario_data["description"]})
if scenarios_to_add:
if alternatives_to_add:
self.db_mngr.add_alternatives({db_item.db_map: alternatives_to_add})
self.db_mngr.add_scenarios({db_item.db_map: scenarios_to_add})
- alternatives = self.db_mngr.get_items(db_item.db_map, "alternative", only_visible=False)
- alternative_id_by_name = {i.name: i.id for i in alternatives}
+ alternatives = self.db_mngr.get_items(db_item.db_map, "alternative")
+ alternative_id_by_name = {i["name"]: i["id"] for i in alternatives}
scenarios = self.db_mngr.get_items(db_item.db_map, "scenario")
- scenario_id_by_name = {i.name: i.id for i in scenarios}
+ scenario_id_by_name = {i["name"]: i["id"] for i in scenarios}
scenario_alternative_id_lists = []
for scenario_name, alternative_name_list in alternative_names_by_scenario.items():
alternative_id_list = [alternative_id_by_name[name] for name in alternative_name_list]
@@ -218,15 +209,13 @@ def duplicate_scenario(self, scenario_item):
scenario_item (ScenarioItem): scenario item to duplicate
"""
db_map = scenario_item.db_map
- existing_names = {i.name for i in self.db_mngr.get_items(db_map, "scenario", only_visible=False)}
- name = unique_name(scenario_item.item_data.name, existing_names)
- self.db_mngr.add_scenarios({db_map: [{"name": name, "description": scenario_item.item_data.description}]})
- alternative_id_list = self.db_mngr.get_scenario_alternative_id_list(
- db_map, scenario_item.id, only_visible=False
- )
- for item in self.db_mngr.get_items(db_map, "scenario", only_visible=False):
- if item.name == name:
+ existing_names = {i["name"] for i in self.db_mngr.get_items(db_map, "scenario")}
+ name = unique_name(scenario_item.item_data["name"], existing_names)
+ self.db_mngr.add_scenarios({db_map: [{"name": name, "description": scenario_item.item_data["description"]}]})
+ alternative_id_list = self.db_mngr.get_scenario_alternative_id_list(db_map, scenario_item.id)
+ for item in self.db_mngr.get_items(db_map, "scenario"):
+ if item["name"] == name:
self.db_mngr.set_scenario_alternatives(
- {db_map: [{"id": item.id, "alternative_id_list": alternative_id_list}]}
+ {db_map: [{"id": item["id"], "alternative_id_list": alternative_id_list}]}
)
break
diff --git a/spinetoolbox/spine_db_editor/mvcmodels/single_and_empty_model_mixins.py b/spinetoolbox/spine_db_editor/mvcmodels/single_and_empty_model_mixins.py
new file mode 100644
index 000000000..b033e0516
--- /dev/null
+++ b/spinetoolbox/spine_db_editor/mvcmodels/single_and_empty_model_mixins.py
@@ -0,0 +1,76 @@
+######################################################################################################################
+# 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 .
+######################################################################################################################
+
+"""Miscellaneous mixins for parameter models."""
+
+from spinedb_api.parameter_value import split_value_and_type
+
+
+class ConvertToDBMixin:
+ """Base class for all mixins that convert model items (name-based) into database items (id-based)."""
+
+ # pylint: disable=no-self-use
+ def _convert_to_db(self, item):
+ """Returns a db item (id-based) from the given model item (name-based).
+
+ Args:
+ item (dict): the model item
+
+ Returns:
+ dict: the db item
+ list: error log
+ """
+ item = item.copy()
+ for field, real_field in self.field_map.items():
+ if field in item:
+ item[real_field] = item.pop(field)
+ return item.copy(), []
+
+
+class SplitValueAndTypeMixin(ConvertToDBMixin):
+ def _convert_to_db(self, item):
+ item, err = super()._convert_to_db(item)
+ value_field, type_field = {
+ "parameter_value": ("value", "type"),
+ "parameter_definition": ("default_value", "default_type"),
+ }[self.item_type]
+ if value_field in item:
+ value, value_type = split_value_and_type(item[value_field])
+ item[value_field] = value
+ item[type_field] = value_type
+ return item, err
+
+
+class MakeEntityOnTheFlyMixin(ConvertToDBMixin):
+ """Makes relationships on the fly."""
+
+ @staticmethod
+ def _make_entity_on_the_fly(item, db_map):
+ """Returns a database entity item (id-based) from the given model parameter_value item (name-based).
+
+ Args:
+ item (dict): the model parameter_value item
+ db_map (DiffDatabaseMapping): the database where the resulting item belongs
+
+ Returns:
+ dict: the db entity item
+ list: error log
+ """
+ entity_class_name = item.get("entity_class_name")
+ entity_class = db_map.get_item("entity_class", name=entity_class_name)
+ if not entity_class:
+ return None, [f"Unknown entity_class {entity_class_name}"] if entity_class_name else []
+ entity_byname = item.get("entity_byname")
+ if not entity_byname:
+ return None, []
+ item = {"entity_class_name": entity_class_name, "entity_byname": entity_byname}
+ return None if db_map.get_item("entity", **item) else item, []
diff --git a/spinetoolbox/spine_db_editor/mvcmodels/single_models.py b/spinetoolbox/spine_db_editor/mvcmodels/single_models.py
new file mode 100644
index 000000000..47c9d5f48
--- /dev/null
+++ b/spinetoolbox/spine_db_editor/mvcmodels/single_models.py
@@ -0,0 +1,417 @@
+######################################################################################################################
+# 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 .
+######################################################################################################################
+
+"""Single models for parameter definitions and values (as 'for a single entity')."""
+from PySide6.QtCore import Qt
+from spinetoolbox.helpers import DB_ITEM_SEPARATOR, plain_to_rich
+from ...mvcmodels.minimal_table_model import MinimalTableModel
+from ..mvcmodels.single_and_empty_model_mixins import SplitValueAndTypeMixin, MakeEntityOnTheFlyMixin
+from ...mvcmodels.shared import PARSED_ROLE, DB_MAP_ROLE
+from .colors import FIXED_FIELD_COLOR
+
+
+class HalfSortedTableModel(MinimalTableModel):
+ def reset_model(self, main_data=None):
+ """Reset model."""
+ if main_data is None:
+ main_data = []
+ self.beginResetModel()
+ self._main_data = sorted(main_data, key=self._sort_key)
+ self.endResetModel()
+
+ def add_rows(self, data):
+ data = [item for item in data if item not in self._main_data]
+ if not data:
+ return
+ self.beginResetModel()
+ self._main_data += data
+ self._main_data.sort(key=self._sort_key)
+ self.endResetModel()
+
+ def _sort_key(self, element):
+ return element
+
+
+class SingleModelBase(HalfSortedTableModel):
+ """Base class for all single models that go in a CompoundModelBase subclass."""
+
+ def __init__(self, parent, db_map, entity_class_id, committed, lazy=False):
+ """
+ Args:
+ parent (CompoundModelBase): the parent model
+ db_map (DatabaseMapping)
+ entity_class_id (int)
+ committed (bool)
+ """
+ super().__init__(parent=parent, header=parent.header, lazy=lazy)
+ self.db_mngr = parent.db_mngr
+ self.db_map = db_map
+ self.entity_class_id = entity_class_id
+ self._auto_filter = {} # Maps field to accepted ids for that field
+ self.committed = committed
+
+ def __lt__(self, other):
+ if self.entity_class_name == other.entity_class_name:
+ return self.db_map.codename < other.db_map.codename
+ return self.entity_class_name < other.entity_class_name
+
+ @property
+ def item_type(self):
+ """The DB item type, required by the data method."""
+ raise NotImplementedError()
+
+ @property
+ def field_map(self):
+ return self._parent.field_map
+
+ def update_items_in_db(self, items):
+ """Update items in db. Required by batch_set_data"""
+ items_to_upd = []
+ error_log = []
+ for item in items:
+ item_to_upd, errors = self._convert_to_db(item)
+ if tuple(item_to_upd.keys()) != ("id",):
+ items_to_upd.append(item_to_upd)
+ if errors:
+ error_log += errors
+ if items_to_upd:
+ self._do_update_items_in_db({self.db_map: items_to_upd})
+ if error_log:
+ self.db_mngr.error_msg.emit({self.db_map: error_log})
+
+ @property
+ def _references(self):
+ raise NotImplementedError()
+
+ @property
+ def entity_class_name(self):
+ return self.db_mngr.get_item(self.db_map, "entity_class", self.entity_class_id)["name"]
+
+ @property
+ def dimension_id_list(self):
+ return self.db_mngr.get_item(self.db_map, "entity_class", self.entity_class_id)["dimension_id_list"]
+
+ @property
+ def fixed_fields(self):
+ return ["entity_class_name", "database"]
+
+ @property
+ def group_fields(self):
+ return ["entity_byname"]
+
+ @property
+ def can_be_filtered(self):
+ return True
+
+ def _mapped_field(self, field):
+ return self.field_map.get(field, field)
+
+ def item_id(self, row):
+ """Returns parameter id for row.
+
+ Args:
+ row (int): row index
+
+ Returns:
+ int: parameter id
+ """
+ return self._main_data[row]
+
+ def item_ids(self):
+ """Returns model's parameter ids.
+
+ Returns:
+ set of int: ids
+ """
+ return set(self._main_data)
+
+ def db_item(self, index):
+ return self._db_item(index.row())
+
+ def _db_item(self, row):
+ id_ = self._main_data[row]
+ return self.db_item_from_id(id_)
+
+ def db_item_from_id(self, id_):
+ return self.db_mngr.get_item(self.db_map, self.item_type, id_)
+
+ def db_items(self):
+ return [self._db_item(row) for row in range(self.rowCount())]
+
+ def flags(self, index):
+ """Make fixed indexes non-editable."""
+ flags = super().flags(index)
+ if self.header[index.column()] in self.fixed_fields:
+ return flags & ~Qt.ItemIsEditable
+ return flags
+
+ def _filter_accepts_row(self, row):
+ item = self.db_mngr.get_item(self.db_map, self.item_type, self._main_data[row])
+ return self.filter_accepts_item(item)
+
+ def filter_accepts_item(self, item):
+ return self._auto_filter_accepts_item(item)
+
+ def set_auto_filter(self, field, values):
+ if values == self._auto_filter.get(field, set()):
+ return False
+ self._auto_filter[field] = values
+ return True
+
+ def _auto_filter_accepts_item(self, item):
+ """Returns the result of the auto filter."""
+ if self._auto_filter is None:
+ return False
+ for field, values in self._auto_filter.items():
+ if values and item.get(field) not in values:
+ return False
+ return True
+
+ def accepted_rows(self):
+ """Yields accepted rows, for convenience."""
+ for row in range(self.rowCount()):
+ if self._filter_accepts_row(row):
+ yield row
+
+ def _get_ref(self, db_item, field):
+ """Returns the item referred by the given field."""
+ ref = self._references.get(field)
+ if ref is None:
+ return {}
+ src_id_key, ref_type = ref
+ ref_if = db_item.get(src_id_key)
+ return self.db_mngr.get_item(self.db_map, ref_type, ref_if)
+
+ def data(self, index, role=Qt.ItemDataRole.DisplayRole):
+ field = self.header[index.column()]
+ if role == Qt.ItemDataRole.BackgroundRole and field in self.fixed_fields:
+ return FIXED_FIELD_COLOR
+ if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole, Qt.ItemDataRole.ToolTipRole):
+ if field == "database":
+ return self.db_map.codename
+ id_ = self._main_data[index.row()]
+ item = self.db_mngr.get_item(self.db_map, self.item_type, id_)
+ if role == Qt.ItemDataRole.ToolTipRole:
+ description = self._get_ref(item, field).get("description")
+ if description:
+ return plain_to_rich(description)
+ mapped_field = self._mapped_field(field)
+ data = item.get(mapped_field)
+ if data and field in self.group_fields:
+ data = DB_ITEM_SEPARATOR.join(data)
+ return data
+ if role == Qt.ItemDataRole.DecorationRole and field == "entity_class_name":
+ return self.db_mngr.entity_class_icon(self.db_map, self.entity_class_id)
+ if role == DB_MAP_ROLE:
+ return self.db_map
+ return super().data(index, role)
+
+ def batch_set_data(self, indexes, data):
+ """Sets data for indexes in batch.
+ Sets data directly in database using db mngr. If successful, updated data will be
+ automatically seen by the data method.
+ """
+
+ def split_value(value, column):
+ if self.header[column] in self.group_fields:
+ return tuple(value.split(DB_ITEM_SEPARATOR))
+ return value
+
+ if not indexes or not data:
+ return False
+ row_data = {}
+ for index, value in zip(indexes, data):
+ row_data.setdefault(index.row(), {})[self.header[index.column()]] = split_value(value, index.column())
+ items = [dict(id=self._main_data[row], **data) for row, data in row_data.items()]
+ self.update_items_in_db(items)
+ return True
+
+
+class FilterEntityAlternativeMixin:
+ """Provides the interface to filter by entity and alternative."""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._filter_alternative_ids = set()
+ self._filter_entity_ids = set()
+
+ def set_filter_entity_ids(self, db_map_class_entity_ids):
+ # Don't accept entity id filters from entities that don't belong in this model
+ filter_entity_ids = set().union(
+ *(
+ ent_ids
+ for (db_map, class_id), ent_ids in db_map_class_entity_ids.items()
+ if db_map == self.db_map and (class_id == self.entity_class_id or class_id in self.dimension_id_list)
+ )
+ )
+ if self._filter_entity_ids == filter_entity_ids:
+ return False
+ self._filter_entity_ids = filter_entity_ids
+ return True
+
+ def set_filter_alternative_ids(self, db_map_alternative_ids):
+ alternative_ids = db_map_alternative_ids.get(self.db_map, set())
+ if self._filter_alternative_ids == alternative_ids:
+ return False
+ self._filter_alternative_ids = alternative_ids
+ return True
+
+ def filter_accepts_item(self, item):
+ """Reimplemented to also account for the entity and alternative filter."""
+ return (
+ super().filter_accepts_item(item)
+ and self._entity_filter_accepts_item(item)
+ and self._alternative_filter_accepts_item(item)
+ )
+
+ def _entity_filter_accepts_item(self, item):
+ """Returns the result of the entity filter."""
+ if not self._filter_entity_ids: # If no entities are selected, only entity classes
+ return True
+ entity_id = item[self._mapped_field("entity_id")]
+ return entity_id in self._filter_entity_ids or bool(set(item["element_id_list"]) & self._filter_entity_ids)
+
+ def _alternative_filter_accepts_item(self, item):
+ """Returns the result of the alternative filter."""
+ if not self._filter_alternative_ids:
+ return True
+ alternative_id = item.get("alternative_id")
+ return alternative_id is None or alternative_id in self._filter_alternative_ids
+
+
+class ParameterMixin:
+ """Provides the data method for parameter values and definitions."""
+
+ @property
+ def value_field(self):
+ return {"parameter_definition": "default_value", "parameter_value": "value"}[self.item_type]
+
+ @property
+ def parameter_definition_id_key(self):
+ return {"parameter_definition": "id", "parameter_value": "parameter_id"}[self.item_type]
+
+ @property
+ def _references(self):
+ return {
+ "entity_class_name": ("entity_class_id", "entity_class"),
+ "entity_byname": ("entity_id", "entity"),
+ "parameter_name": (self.parameter_definition_id_key, "parameter_definition"),
+ "value_list_name": ("value_list_id", "parameter_value_list"),
+ "description": ("id", "parameter_definition"),
+ "value": ("id", "parameter_value"),
+ "default_value": ("id", "parameter_definition"),
+ "database": ("database", None),
+ "alternative_name": ("alternative_id", "alternative"),
+ }
+
+ def data(self, index, role=Qt.ItemDataRole.DisplayRole):
+ """Gets the id and database for the row, and reads data from the db manager
+ using the item_type property.
+ Paint the object_class icon next to the name.
+ Also paint background of fixed indexes gray and apply custom format to JSON fields."""
+ field = self.header[index.column()]
+ # Display, edit, tool tip, alignment role of 'value fields'
+ if field == self.value_field and role in (
+ Qt.ItemDataRole.DisplayRole,
+ Qt.ItemDataRole.EditRole,
+ Qt.ItemDataRole.ToolTipRole,
+ Qt.TextAlignmentRole,
+ PARSED_ROLE,
+ ):
+ id_ = self._main_data[index.row()]
+ item = self.db_mngr.get_item(self.db_map, self.item_type, id_)
+ return self.db_mngr.get_value(self.db_map, item, role)
+ return super().data(index, role)
+
+
+class EntityMixin:
+ def update_items_in_db(self, items):
+ """Overriden to create entities on the fly first."""
+ for item in items:
+ item["entity_class_name"] = self.entity_class_name
+ entities = []
+ error_log = []
+ for item in items:
+ entity, errors = self._make_entity_on_the_fly(item, self.db_map)
+ if entity:
+ entities.append(entity)
+ if errors:
+ error_log.extend(errors)
+ if entities:
+ self.db_mngr.add_entities({self.db_map: entities})
+ if error_log:
+ self.db_mngr.error_msg.emit({self.db_map: error_log})
+ super().update_items_in_db(items)
+
+ def _do_update_items_in_db(self, db_map_data):
+ raise NotImplementedError()
+
+
+class SingleParameterDefinitionModel(SplitValueAndTypeMixin, ParameterMixin, SingleModelBase):
+ """A parameter_definition model for a single entity_class."""
+
+ @property
+ def item_type(self):
+ return "parameter_definition"
+
+ def _sort_key(self, element):
+ item = self.db_item_from_id(element)
+ return item.get("name", "")
+
+ def _do_update_items_in_db(self, db_map_data):
+ self.db_mngr.update_parameter_definitions(db_map_data)
+
+
+class SingleParameterValueModel(
+ MakeEntityOnTheFlyMixin,
+ SplitValueAndTypeMixin,
+ ParameterMixin,
+ EntityMixin,
+ FilterEntityAlternativeMixin,
+ SingleModelBase,
+):
+ """A parameter_value model for a single entity_class."""
+
+ @property
+ def item_type(self):
+ return "parameter_value"
+
+ def _sort_key(self, element):
+ item = self.db_item_from_id(element)
+ return (item.get("entity_byname", ()), item.get("parameter_name", ""), item.get("alternative_name", ""))
+
+ def _do_update_items_in_db(self, db_map_data):
+ self.db_mngr.update_parameter_values(db_map_data)
+
+
+class SingleEntityAlternativeModel(MakeEntityOnTheFlyMixin, EntityMixin, FilterEntityAlternativeMixin, SingleModelBase):
+ """An entity_alternative model for a single entity_class."""
+
+ @property
+ def item_type(self):
+ return "entity_alternative"
+
+ def _sort_key(self, element):
+ item = self.db_item_from_id(element)
+ return (item.get("entity_byname", ()), item.get("alternative_name", ""))
+
+ @property
+ def _references(self):
+ return {
+ "entity_class_name": ("entity_class_id", "entity_class"),
+ "entity_byname": ("entity_id", "entity"),
+ "alternative_name": ("alternative_id", "alternative"),
+ "database": ("database", None),
+ }
+
+ def _do_update_items_in_db(self, db_map_data):
+ self.db_mngr.update_entity_alternatives(db_map_data)
diff --git a/spinetoolbox/spine_db_editor/mvcmodels/single_parameter_models.py b/spinetoolbox/spine_db_editor/mvcmodels/single_parameter_models.py
deleted file mode 100644
index 1a4ce111d..000000000
--- a/spinetoolbox/spine_db_editor/mvcmodels/single_parameter_models.py
+++ /dev/null
@@ -1,523 +0,0 @@
-######################################################################################################################
-# Copyright (C) 2017-2022 Spine project consortium
-# 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 .
-######################################################################################################################
-
-"""
-Single models for parameter definitions and values (as 'for a single entity').
-"""
-
-from PySide6.QtCore import Qt
-from spinetoolbox.helpers import DB_ITEM_SEPARATOR
-from ...mvcmodels.minimal_table_model import MinimalTableModel
-from ..mvcmodels.parameter_mixins import (
- FillInParameterNameMixin,
- FillInValueListIdMixin,
- MakeRelationshipOnTheFlyMixin,
- FillInAlternativeIdMixin,
- FillInParameterDefinitionIdsMixin,
- FillInEntityIdsMixin,
- ImposeEntityClassIdMixin,
-)
-from ...mvcmodels.shared import PARSED_ROLE, DB_MAP_ROLE
-from .colors import FIXED_FIELD_COLOR
-
-
-class HalfSortedTableModel(MinimalTableModel):
- def reset_model(self, main_data=None):
- """Reset model."""
- if main_data is None:
- main_data = list()
- self.beginResetModel()
- self._main_data = sorted(main_data, key=self._sort_key)
- self.endResetModel()
-
- def add_rows(self, data):
- data = [item for item in data if item not in self._main_data]
- if not data:
- return
- self.beginResetModel()
- self._main_data += data
- self._main_data.sort(key=self._sort_key)
- self.endResetModel()
-
- def _sort_key(self, element):
- return element
-
-
-class SingleParameterModel(HalfSortedTableModel):
- """A parameter model for a single entity_class to go in a CompoundParameterModel.
- Provides methods to associate the model to an entity_class as well as
- to filter entities within the class.
- """
-
- def __init__(self, header, db_mngr, db_map, entity_class_id, committed, lazy=False):
- """Init class.
-
- Args:
- header (list): list of field names for the header
- """
- super().__init__(header=header, lazy=lazy)
- self.db_mngr = db_mngr
- self.db_map = db_map
- self.entity_class_id = entity_class_id
- self._auto_filter = dict() # Maps field to accepted ids for that field
- self.committed = committed
-
- def __lt__(self, other):
- if self.entity_class_name == other.entity_class_name:
- return self.db_map.codename < other.db_map.codename
- return self.entity_class_name < other.entity_class_name
-
- @property
- def item_type(self):
- """The item type, either 'parameter_value' or 'parameter_definition', required by the data method."""
- raise NotImplementedError()
-
- @property
- def entity_class_type(self):
- """The entity_class type, either 'object_class' or 'relationship_class'."""
- raise NotImplementedError()
-
- @property
- def entity_class_name_field(self):
- return {"object_class": "object_class_name", "relationship_class": "relationship_class_name"}[
- self.entity_class_type
- ]
-
- @property
- def entity_class_name(self):
- return self.db_mngr.get_item(self.db_map, self.entity_class_type, self.entity_class_id)["name"]
-
- @property
- def entity_class_id_key(self):
- return {"object_class": "object_class_id", "relationship_class": "relationship_class_id"}[
- self.entity_class_type
- ]
-
- @property
- def value_field(self):
- return {"parameter_definition": "default_value", "parameter_value": "value"}[self.item_type]
-
- @property
- def fixed_fields(self):
- return {
- "object_class": ["object_class_name", "database"],
- "relationship_class": ["relationship_class_name", "object_class_name_list", "database"],
- }[self.entity_class_type]
-
- @property
- def group_fields(self):
- return {
- "object_class": {"parameter_definition": [], "parameter_value": []},
- "relationship_class": {
- "parameter_definition": ["object_class_name_list"],
- "parameter_value": ["object_name_list"],
- },
- }[self.entity_class_type][self.item_type]
-
- @property
- def parameter_definition_id_key(self):
- return {"parameter_definition": "id", "parameter_value": "parameter_id"}[self.item_type]
-
- @property
- def can_be_filtered(self):
- return True
-
- def item_id(self, row):
- """Returns parameter id for row.
-
- Args:
- row (int): row index
-
- Returns:
- int: parameter id
- """
- return self._main_data[row]
-
- def item_ids(self):
- """Returns model's parameter ids.
-
- Returns:
- set of int: ids
- """
- return set(self._main_data)
-
- def db_item(self, index):
- return self._db_item(index.row())
-
- def _db_item(self, row):
- id_ = self._main_data[row]
- return self.db_item_from_id(id_)
-
- def db_item_from_id(self, id_):
- return self.db_mngr.get_item(self.db_map, self.item_type, id_)
-
- def db_items(self):
- return [self._db_item(row) for row in range(self.rowCount())]
-
- def flags(self, index):
- """Make fixed indexes non-editable."""
- flags = super().flags(index)
- if self.header[index.column()] in self.fixed_fields:
- return flags & ~Qt.ItemIsEditable
- return flags
-
- def get_field_item_data(self, field):
- """Returns item data for given field.
-
- Args:
- field (str): A field from the header
-
- Returns:
- str, str
- """
- return {
- "object_class_name": ("object_class_id", "object_class"),
- "relationship_class_name": ("relationship_class_id", "relationship_class"),
- "object_class_name_list": ("relationship_class_id", "relationship_class"),
- "object_name": ("object_id", "object"),
- "object_name_list": ("relationship_id", "relationship"),
- "parameter_name": (self.parameter_definition_id_key, "parameter_definition"),
- "value_list_name": ("value_list_id", "parameter_value_list"),
- "description": ("id", "parameter_definition"),
- "value": ("id", "parameter_value"),
- "default_value": ("id", "parameter_definition"),
- "database": ("database", None),
- "alternative_id": ("alternative_id", "alternative"),
- }.get(field)
-
- def get_id_key(self, field):
- field_item_data = self.get_field_item_data(field)
- if field_item_data is None:
- return None
- return field_item_data[0]
-
- def get_field_item(self, field, db_item):
- """Returns a db item corresponding to the given field from the table header,
- or an empty dict if the field doesn't contain db items.
- """
- field_item_data = self.get_field_item_data(field)
- if field_item_data is None:
- return {}
- id_key, item_type = field_item_data
- item_id = db_item.get(id_key)
- return self.db_mngr.get_item(self.db_map, item_type, item_id)
-
- def data(self, index, role=Qt.ItemDataRole.DisplayRole):
- """Gets the id and database for the row, and reads data from the db manager
- using the item_type property.
- Paint the object_class icon next to the name.
- Also paint background of fixed indexes gray and apply custom format to JSON fields."""
- field = self.header[index.column()]
- # Background role
- if role == Qt.ItemDataRole.BackgroundRole and field in self.fixed_fields:
- return FIXED_FIELD_COLOR
- # Display, edit, tool tip, alignment role of 'json fields'
- if field == self.value_field and role in (
- Qt.ItemDataRole.DisplayRole,
- Qt.ItemDataRole.EditRole,
- Qt.ItemDataRole.ToolTipRole,
- Qt.TextAlignmentRole,
- PARSED_ROLE,
- ):
- id_ = self._main_data[index.row()]
- return self.db_mngr.get_value(self.db_map, self.item_type, id_, role)
- if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole, Qt.ItemDataRole.ToolTipRole):
- if field == "database":
- return self.db_map.codename
- id_ = self._main_data[index.row()]
- item = self.db_mngr.get_item(self.db_map, self.item_type, id_)
- if role == Qt.ItemDataRole.ToolTipRole:
- description = self.get_field_item(field, item).get("description", None)
- if description not in (None, ""):
- return description
- data = item.get(field)
- if data and field in self.group_fields:
- data = DB_ITEM_SEPARATOR.join(data)
- return data
- if role == Qt.ItemDataRole.DecorationRole and field == self.entity_class_name_field:
- return self.db_mngr.entity_class_icon(self.db_map, self.entity_class_type, self.entity_class_id)
- if role == DB_MAP_ROLE:
- return self.db_map
- return super().data(index, role)
-
- def batch_set_data(self, indexes, data):
- """Sets data for indexes in batch.
- Sets data directly in database using db mngr. If successful, updated data will be
- automatically seen by the data method.
- """
-
- def split_value(value, column):
- if self.header[column] in self.group_fields:
- return tuple(value.split(DB_ITEM_SEPARATOR))
- return value
-
- if not indexes or not data:
- return False
- row_data = dict()
- for index, value in zip(indexes, data):
- row_data.setdefault(index.row(), {})[self.header[index.column()]] = split_value(value, index.column())
- items = [dict(id=self._main_data[row], **data) for row, data in row_data.items()]
- self.update_items_in_db(items)
- return True
-
- def update_items_in_db(self, items):
- """Update items in db. Required by batch_set_data"""
- raise NotImplementedError()
-
- def _filter_accepts_row(self, row):
- item = self.db_mngr.get_item(self.db_map, self.item_type, self._main_data[row])
- return self.filter_accepts_item(item)
-
- def filter_accepts_item(self, item):
- return self._auto_filter_accepts_item(item)
-
- def set_auto_filter(self, field, values):
- if values == self._auto_filter.get(field, set()):
- return False
- self._auto_filter[field] = values
- return True
-
- def _auto_filter_accepts_item(self, item):
- """Returns the result of the auto filter."""
- if self._auto_filter is None:
- return False
- for field, values in self._auto_filter.items():
- if values and item.get(field) not in values:
- return False
- return True
-
- def accepted_rows(self):
- """Yields accepted rows, for convenience."""
- for row in range(self.rowCount()):
- if self._filter_accepts_row(row):
- yield row
-
- def _get_field_item(self, field, id_):
- """Returns a item from the db_mngr.get_item depending on the field.
- If a field doesn't correspond to a item in the database then an empty dict is returned.
- """
- header_to_id = {
- "object_class_name": ("entity_class_id", "object_class"),
- "relationship_class_name": ("entity_class_id", "relationship_class"),
- "object_name": ("entity_id", "object"),
- "object_name_list": ("entity_id", "relationship"),
- "parameter_name": (self.parameter_definition_id_key, "parameter_definition"),
- }
- id_field_item_type = header_to_id.get(field)
- if id_field_item_type is None:
- return {}
- id_field, item_type = id_field_item_type
- data = self.db_mngr.get_item(self.db_map, self.item_type, id_)
- item_id = data.get(id_field)
- return self.db_mngr.get_item(self.db_map, item_type, item_id)
-
-
-class SingleObjectParameterMixin:
- """Associates a parameter model with a single object_class."""
-
- @property
- def entity_class_type(self):
- return "object_class"
-
-
-class SingleRelationshipParameterMixin:
- """Associates a parameter model with a single relationship_class."""
-
- @property
- def entity_class_type(self):
- return "relationship_class"
-
-
-class SingleParameterDefinitionMixin(FillInParameterNameMixin, FillInValueListIdMixin):
- """A parameter_definition model for a single entity_class."""
-
- @property
- def item_type(self):
- return "parameter_definition"
-
- def _sort_key(self, element):
- item = self.db_item_from_id(element)
- return item["parameter_name"]
-
- def update_items_in_db(self, items):
- """Update items in db.
-
- Args:
- items (list): dictionary-items
- """
- self.build_lookup_dictionary({self.db_map: items})
- param_defs = list()
- error_log = list()
- for item in items:
- param_def, errors = self._convert_to_db(item, self.db_map)
- if tuple(param_def.keys()) != ("id",):
- param_defs.append(param_def)
- if errors:
- error_log += errors
- if param_defs:
- self.db_mngr.update_parameter_definitions({self.db_map: param_defs})
- if error_log:
- self.db_mngr.error_msg.emit({self.db_map: error_log})
-
-
-class SingleParameterValueMixin(
- FillInAlternativeIdMixin, ImposeEntityClassIdMixin, FillInParameterDefinitionIdsMixin, FillInEntityIdsMixin
-):
- """A parameter_value model for a single entity_class."""
-
- _filter_db_map_class_entity_ids = dict()
- _filter_alternative_ids = set()
- _filter_entity_ids = set()
-
- @property
- def item_type(self):
- return "parameter_value"
-
- @property
- def entity_type(self):
- """Either 'object' or "relationship'."""
- raise NotImplementedError()
-
- @property
- def entity_id_key(self):
- return {"object": "object_id", "relationship": "relationship_id"}[self.entity_type]
-
- @property
- def entity_name_key(self):
- return {"object": "object_name", "relationship": "object_name_list"}[self.entity_type]
-
- @property
- def entity_name_key_in_cache(self):
- return {"object": "name", "relationship": "object_name_list"}[self.entity_type]
-
- def _sort_key(self, element):
- item = self.db_item_from_id(element)
- return tuple(item[k] for k in (self.entity_name_key, "parameter_name", "alternative_name"))
-
- def set_filter_entity_ids(self, db_map_class_entity_ids):
- if self._filter_db_map_class_entity_ids == db_map_class_entity_ids:
- return False
- self._filter_db_map_class_entity_ids = db_map_class_entity_ids
- self._filter_entity_ids = db_map_class_entity_ids.get((self.db_map, self.entity_class_id), set())
- return True
-
- def set_filter_alternative_ids(self, db_map_alternative_ids):
- alternative_ids = db_map_alternative_ids.get(self.db_map, set())
- if self._filter_alternative_ids == alternative_ids:
- return False
- self._filter_alternative_ids = alternative_ids
- return True
-
- def filter_accepts_item(self, item):
- """Reimplemented to also account for the entity and alternative filter."""
- return (
- super().filter_accepts_item(item)
- and self._entity_filter_accepts_item(item)
- and self._alternative_filter_accepts_item(item)
- )
-
- def _entity_filter_accepts_item(self, item):
- """Returns the result of the entity filter."""
- object_class_id = item["object_class_id"]
- if not self._filter_db_map_class_entity_ids:
- return True
- try:
- active_class_ids = [i[1] for i in self._filter_db_map_class_entity_ids.keys()]
- except TypeError:
- active_class_ids = []
- if object_class_id and object_class_id not in active_class_ids:
- return True
- entity_id = item["entity_id"]
- return entity_id in self._filter_entity_ids
-
- def _alternative_filter_accepts_item(self, item):
- """Returns the result of the alternative filter."""
- if not self._filter_alternative_ids:
- return True
- alternative_id = item["alternative_id"]
- return alternative_id in self._filter_alternative_ids
-
- def update_items_in_db(self, items):
- """Update items in db.
-
- Args:
- items (list): dictionary-items
- """
- param_vals = list()
- error_log = list()
- db_map_data = dict()
- db_map_data[self.db_map] = items
- self.build_lookup_dictionary(db_map_data)
- for item in items:
- param_val, errors = self._convert_to_db(item, self.db_map)
- if tuple(param_val.keys()) != ("id",):
- param_vals.append(param_val)
- if errors:
- error_log += errors
- if param_vals:
- self.db_mngr.update_parameter_values({self.db_map: param_vals})
- if error_log:
- self.db_mngr.error_msg.emit({self.db_map: error_log})
-
-
-class SingleObjectParameterDefinitionModel(
- SingleObjectParameterMixin, SingleParameterDefinitionMixin, SingleParameterModel
-):
- """An object parameter_definition model for a single object_class."""
-
-
-class SingleRelationshipParameterDefinitionModel(
- SingleRelationshipParameterMixin, SingleParameterDefinitionMixin, SingleParameterModel
-):
- """A relationship parameter_definition model for a single relationship_class."""
-
-
-class SingleObjectParameterValueModel(SingleObjectParameterMixin, SingleParameterValueMixin, SingleParameterModel):
- """An object parameter_value model for a single object_class."""
-
- @property
- def entity_type(self):
- return "object"
-
-
-class SingleRelationshipParameterValueModel(
- SingleRelationshipParameterMixin, MakeRelationshipOnTheFlyMixin, SingleParameterValueMixin, SingleParameterModel
-):
- """A relationship parameter_value model for a single relationship_class."""
-
- @property
- def entity_type(self):
- return "relationship"
-
- def update_items_in_db(self, items):
- """Update items in db.
-
- Args:
- items (list): dictionary-items
- """
- for item in items:
- item["relationship_class_name"] = self.entity_class_name
- db_map_data = {self.db_map: items}
- self.build_lookup_dictionaries(db_map_data)
- db_map_relationships = dict()
- db_map_error_log = dict()
- for db_map, data in db_map_data.items():
- for item in data:
- relationship, err = self._make_relationship_on_the_fly(item, db_map)
- if relationship:
- db_map_relationships.setdefault(db_map, []).append(relationship)
- if err:
- db_map_error_log.setdefault(db_map, []).extend(err)
- if any(db_map_relationships.values()):
- self.db_mngr.add_relationships(db_map_relationships)
- if db_map_error_log:
- self.db_mngr.error_msg.emit(db_map_error_log)
- super().update_items_in_db(items)
diff --git a/spinetoolbox/spine_db_editor/mvcmodels/tool_feature_item.py b/spinetoolbox/spine_db_editor/mvcmodels/tool_feature_item.py
deleted file mode 100644
index 1bdc8665f..000000000
--- a/spinetoolbox/spine_db_editor/mvcmodels/tool_feature_item.py
+++ /dev/null
@@ -1,369 +0,0 @@
-######################################################################################################################
-# Copyright (C) 2017-2022 Spine project consortium
-# 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 .
-######################################################################################################################
-
-"""
-Classes to represent tool and feature items in a tree.
-"""
-from PySide6.QtCore import Qt
-from .tree_item_utility import GrayIfLastMixin, EditableMixin, EmptyChildRootItem, LeafItem, StandardTreeItem
-
-_FEATURE_ICON = "\uf5bc" # splotch
-_TOOL_ICON = "\uf6e3" # hammer
-_METHOD_ICON = "\uf1de" # sliders-h
-
-
-class FeatureRootItem(EmptyChildRootItem):
- """A feature root item."""
-
- @property
- def item_type(self):
- return "feature"
-
- @property
- def display_data(self):
- return "feature"
-
- @property
- def icon_code(self):
- return _FEATURE_ICON
-
- def empty_child(self):
- return FeatureLeafItem()
-
- def _make_child(self, id_):
- return FeatureLeafItem(id_)
-
-
-class ToolRootItem(EmptyChildRootItem):
- """A tool root item."""
-
- @property
- def item_type(self):
- return "tool"
-
- @property
- def display_data(self):
- return "tool"
-
- @property
- def icon_code(self):
- return _TOOL_ICON
-
- def empty_child(self):
- return ToolLeafItem()
-
- def _make_child(self, id_):
- return ToolLeafItem(id_)
-
-
-class FeatureLeafItem(GrayIfLastMixin, EditableMixin, LeafItem):
- """A feature leaf item."""
-
- @property
- def item_type(self):
- return "feature"
-
- def _make_item_data(self):
- return {"name": "Enter new feature here...", "description": ""}
-
- @property
- def item_data(self):
- if not self.id:
- return self._make_item_data()
- item_data = self.db_mngr.get_item(self.db_map, self.item_type, self.id)
- if not item_data:
- return {}
- item_data["name"] = self.model.make_feature_name(
- item_data["entity_class_name"], item_data["parameter_definition_name"]
- )
- return item_data
-
- @property
- def tool_tip(self):
- return "
Drag this item and drop it onto a tool feature item below to create a tool feature
Please enter a valid method for feature '{self.tool_feature_item.name}'.
"
- )
- return None
- return method_index
-
- def add_item_to_db(self, db_item):
- self.db_mngr.add_tool_feature_methods({self.db_map: [db_item]})
-
- def update_item_in_db(self, db_item):
- self.db_mngr.update_tool_feature_methods({self.db_map: [db_item]})
diff --git a/spinetoolbox/spine_db_editor/mvcmodels/tool_feature_model.py b/spinetoolbox/spine_db_editor/mvcmodels/tool_feature_model.py
deleted file mode 100644
index 632535801..000000000
--- a/spinetoolbox/spine_db_editor/mvcmodels/tool_feature_model.py
+++ /dev/null
@@ -1,156 +0,0 @@
-######################################################################################################################
-# Copyright (C) 2017-2022 Spine project consortium
-# 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 .
-######################################################################################################################
-"""
-Models to represent tools and features in a tree.
-"""
-import json
-from PySide6.QtCore import QMimeData, Qt
-from .tree_model_base import TreeModelBase
-from .tree_item_utility import StandardDBItem
-from .tool_feature_item import FeatureRootItem, ToolRootItem
-
-
-class ToolFeatureModel(TreeModelBase):
- """A model to display tools and features in a tree view.
-
-
- Args:
- parent (SpineDBEditor)
- db_mngr (SpineDBManager)
- db_maps (iter): DiffDatabaseMapping instances
- """
-
- def __init__(self, parent, db_mngr, *db_maps):
- """Initialize class"""
- super().__init__(parent, db_mngr, *db_maps)
- self._db_map_feature_data = {}
- self._db_map_feature_methods = {}
-
- @staticmethod
- def _make_db_item(db_map):
- return StandardDBItem(db_map)
-
- @staticmethod
- def _top_children():
- return [FeatureRootItem(), ToolRootItem()]
-
- @staticmethod
- def make_feature_name(entity_class_name, parameter_definition_name):
- if entity_class_name is None:
- entity_class_name = ""
- if parameter_definition_name is None:
- parameter_definition_name = ""
- return entity_class_name + "/" + parameter_definition_name
-
- def _begin_set_features(self, db_map):
- parameter_definitions = self.db_mngr.get_items(db_map, "parameter_definition", only_visible=False)
- key = lambda x: self.make_feature_name(
- x.get("object_class_name") or x.get("relationship_class_name"), x["parameter_name"]
- )
- self._db_map_feature_data[db_map] = {
- key(x): (x["id"], x["value_list_id"]) for x in parameter_definitions if x["value_list_id"]
- }
-
- def get_all_feature_names(self, db_map):
- self._begin_set_features(db_map)
- return list(self._db_map_feature_data.get(db_map, {}).keys())
-
- def get_feature_data(self, db_map, feature_name):
- return self._db_map_feature_data.get(db_map, {}).get(feature_name)
-
- def _begin_set_feature_method(self, db_map, parameter_value_list_id):
- parameter_value_list = self.db_mngr.get_item(
- db_map, "parameter_value_list", parameter_value_list_id, only_visible=False
- )
- value_index_list = parameter_value_list["value_index_list"]
- display_value_list = self.db_mngr.get_parameter_value_list(
- db_map, parameter_value_list_id, role=Qt.ItemDataRole.DisplayRole, only_visible=False
- )
- self._db_map_feature_methods.setdefault(db_map, {})[parameter_value_list_id] = dict(
- zip(display_value_list, value_index_list)
- )
-
- def get_all_feature_methods(self, db_map, parameter_value_list_id):
- self._begin_set_feature_method(db_map, parameter_value_list_id)
- return list(self._db_map_feature_methods.get(db_map, {}).get(parameter_value_list_id, {}).keys())
-
- def get_method_index(self, db_map, parameter_value_list_id, method):
- return self._db_map_feature_methods.get(db_map, {}).get(parameter_value_list_id, {}).get(method)
-
- def supportedDropActions(self):
- return Qt.CopyAction | Qt.MoveAction
-
- def mimeData(self, indexes):
- """
- Builds a dict mapping db name to item type to a list of ids.
-
- Returns:
- QMimeData
- """
- items = {self.item_from_index(ind): None for ind in indexes} # NOTE: this avoids dupes and keeps order
- d = {}
- for item in items:
- parent_item = item.parent_item
- db_row = self.db_row(parent_item)
- parent_type = parent_item.item_type
- master_key = ";;".join([str(db_row), parent_type])
- d.setdefault(master_key, []).append(item.child_number())
- data = json.dumps(d)
- mime = QMimeData()
- mime.setText(data)
- return mime
-
- def canDropMimeData(self, data, drop_action, row, column, parent):
- if not parent.isValid():
- return False
- if not data.hasText():
- return False
- try:
- data = json.loads(data.text())
- except ValueError:
- return False
- if not isinstance(data, dict):
- return False
- # Check that all source data comes from the same db and parent
- if len(data) != 1:
- return False
- master_key = next(iter(data))
- db_row, parent_type = master_key.split(";;")
- db_row = int(db_row)
- if parent_type != "feature":
- return False
- # Check that target is in the same db as source
- tool_item = self.item_from_index(parent)
- if db_row != self.db_row(tool_item):
- return False
- return True
-
- def dropMimeData(self, data, drop_action, row, column, parent):
- tool_feat_root_item = self.item_from_index(parent)
- master_key, feature_rows = json.loads(data.text()).popitem()
- db_row, _parent_type = master_key.split(";;")
- db_row = int(db_row)
- feat_root_item = self._invisible_root_item.child(db_row).child(0)
- db_items = []
- for feat_row in feature_rows:
- item = feat_root_item.child(feat_row)
- feature_id = item.id
- if feature_id in tool_feat_root_item.feature_id_list:
- continue
- parameter_value_list_id = item.item_data.get("parameter_value_list_id")
- db_item = {
- "tool_id": tool_feat_root_item.parent_item.id,
- "feature_id": feature_id,
- "parameter_value_list_id": parameter_value_list_id,
- }
- db_items.append(db_item)
- self.db_mngr.add_tool_features({tool_feat_root_item.db_map: db_items})
- return True
diff --git a/spinetoolbox/spine_db_editor/mvcmodels/tree_item_utility.py b/spinetoolbox/spine_db_editor/mvcmodels/tree_item_utility.py
index df4d0ba8d..942e46ea7 100644
--- a/spinetoolbox/spine_db_editor/mvcmodels/tree_item_utility.py
+++ b/spinetoolbox/spine_db_editor/mvcmodels/tree_item_utility.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,14 +10,11 @@
# this program. If not, see .
######################################################################################################################
-"""
-A tree model for parameter_value lists.
-"""
-
+"""A tree model for parameter_value lists."""
from PySide6.QtCore import Qt
from PySide6.QtGui import QBrush, QFont, QIcon, QGuiApplication
from spinetoolbox.mvcmodels.minimal_tree_model import TreeItem
-from spinetoolbox.helpers import CharIconEngine, bisect_chunks
+from spinetoolbox.helpers import CharIconEngine, bisect_chunks, plain_to_tool_tip
from spinetoolbox.fetch_parent import FlexibleFetchParent
@@ -39,8 +37,7 @@ def display_data(self):
def icon_code(self):
return None
- @property
- def tool_tip(self):
+ def tool_tip(self, column):
return None
@property
@@ -51,15 +48,15 @@ def display_icon(self):
return QIcon(engine.pixmap())
def data(self, column, role=Qt.ItemDataRole.DisplayRole):
+ if role == Qt.ItemDataRole.ToolTipRole:
+ return self.tool_tip(column)
if column != 0:
return None
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
return self.display_data
if role == Qt.ItemDataRole.DecorationRole:
return self.display_icon
- if role == Qt.ItemDataRole.ToolTipRole:
- return self.tool_tip
- return super().data(column, role)
+ return super().data(0, role)
def set_data(self, column, value, role=Qt.ItemDataRole.DisplayRole):
return False
@@ -87,7 +84,7 @@ class GrayIfLastMixin:
"""Paints the item gray if it's the last."""
def data(self, column, role=Qt.ItemDataRole.DisplayRole):
- if role == Qt.ForegroundRole and self.child_number() == self.parent_item.child_count() - 1:
+ if role == Qt.ForegroundRole and self.child_number() == self.parent_item.row_count() - 1:
gray_color = QGuiApplication.palette().text().color()
gray_color.setAlpha(128)
gray_brush = QBrush(gray_color)
@@ -134,15 +131,6 @@ def insert_children_sorted(self, children):
return False
return True
- def _resort(self):
- # FIXME MM Needed?
- non_empty_children, empty_child = self.children[:-1], self.children[-1]
- non_empty_children.sort(key=self._children_sort_key)
- self.children = non_empty_children + [empty_child]
- top = self.model.index_from_item(self.child(0))
- bottom = self.model.index_from_item(self.child(-1))
- self.model.dataChanged.emit(top, bottom)
-
class FetchMoreMixin:
def __init__(self, *args, **kwargs):
@@ -158,6 +146,7 @@ def __init__(self, *args, **kwargs):
def tear_down(self):
super().tear_down()
self._natural_fetch_parent.set_obsolete(True)
+ self._natural_fetch_parent.deleteLater()
@property
def fetch_item_type(self):
@@ -179,6 +168,12 @@ def fetch_more(self):
def _make_child(self, id_):
raise NotImplementedError()
+ def _do_make_child(self, id_):
+ child = self._created_children.get(id_)
+ if child is None:
+ child = self._created_children[id_] = self._make_child(id_)
+ return child
+
def accepts_item(self, item, db_map):
return True
@@ -197,8 +192,8 @@ def handle_items_added(self, db_map_data):
continue
ids = ids_committed if item.get("commit_id") is not None else ids_uncommitted
ids.append(item["id"])
- children_committed = [self._make_child(id_) for id_ in ids_committed]
- children_uncommitted = [self._make_child(id_) for id_ in ids_uncommitted]
+ children_committed = [self._do_make_child(id_) for id_ in ids_committed]
+ children_uncommitted = [self._do_make_child(id_) for id_ in ids_uncommitted]
self.insert_children_sorted(children_committed)
self.insert_children(len(self.non_empty_children), children_uncommitted)
@@ -221,21 +216,21 @@ def handle_items_updated(self, db_map_data):
self.model.dataChanged.emit(index, index)
if leaf_item.children:
top_left = self.model.index_from_item(leaf_item.child(0))
- bottom_right = self.model.index_from_item(leaf_item.child(-1))
+ bottom_right = self.model.index_from_item(leaf_item.child(leaf_item.child_count() - 1))
self.model.dataChanged.emit(top_left, bottom_right)
class StandardDBItem(SortChildrenMixin, StandardTreeItem):
"""An item representing a db."""
- def __init__(self, db_map):
+ def __init__(self, model, db_map):
"""Init class.
- Args
- db_mngr (SpineDBManager)
- db_map (DiffDatabaseMapping)
+ Args:
+ model (MinimalTreeModel)
+ db_map (DatabaseMapping)
"""
- super().__init__()
+ super().__init__(model)
self.db_map = db_map
@property
@@ -252,30 +247,14 @@ def data(self, column, role=Qt.ItemDataRole.DisplayRole):
return self.db_map.codename
-class RootItem(SortChildrenMixin, BoldTextMixin, FetchMoreMixin, StandardTreeItem):
- """A root item."""
-
- @property
- def item_type(self):
- raise NotImplementedError
-
- @property
- def db_map(self):
- return self.parent_item.db_map
-
-
-class EmptyChildRootItem(EmptyChildMixin, RootItem):
- def empty_child(self):
- raise NotImplementedError
-
-
class LeafItem(StandardTreeItem):
- def __init__(self, identifier=None):
+ def __init__(self, model, identifier=None):
"""
Args:
+ model (MinimalTreeModel)
identifier (int, optional): item's database id
"""
- super().__init__()
+ super().__init__(model)
self._id = identifier
def _make_item_data(self):
@@ -303,6 +282,11 @@ def item_data(self):
def name(self):
return self.item_data["name"]
+ def tool_tip(self, column):
+ if column != 0 and (header_data := self.header_data(column)) == "description":
+ return plain_to_tool_tip(self.item_data.get(header_data))
+ return super().tool_tip(column)
+
def add_item_to_db(self, db_item):
raise NotImplementedError()
diff --git a/spinetoolbox/spine_db_editor/mvcmodels/tree_model_base.py b/spinetoolbox/spine_db_editor/mvcmodels/tree_model_base.py
index a3b912315..7b8f0da6b 100644
--- a/spinetoolbox/spine_db_editor/mvcmodels/tree_model_base.py
+++ b/spinetoolbox/spine_db_editor/mvcmodels/tree_model_base.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -8,10 +9,9 @@
# 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 .
######################################################################################################################
-"""
-Models to represent things in a tree.
-"""
-from PySide6.QtCore import Qt, QModelIndex
+
+"""Models to represent things in a tree."""
+from PySide6.QtCore import QObject, Qt, QModelIndex, Slot
from spinetoolbox.mvcmodels.minimal_tree_model import MinimalTreeModel
from .tree_item_utility import StandardTreeItem
@@ -24,12 +24,13 @@ def __init__(self, db_editor, db_mngr, *db_maps):
Args:
db_editor (SpineDBEditor)
db_mngr (SpineDBManager)
- *db_maps: DiffDatabaseMapping instances
+ *db_maps: DatabaseMapping instances
"""
super().__init__(db_editor)
self.db_editor = db_editor
self.db_mngr = db_mngr
self.db_maps = db_maps
+ self.destroyed.connect(self._tear_down_tree)
def columnCount(self, parent=QModelIndex()):
"""Returns the number of columns under the given parent. Always 2.
@@ -47,19 +48,14 @@ def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
def build_tree(self):
"""Builds tree."""
self.beginResetModel()
+ self._invisible_root_item.tear_down_recursively()
self._invisible_root_item = StandardTreeItem(self)
self.endResetModel()
for db_map in self.db_maps:
db_item = self._make_db_item(db_map)
self._invisible_root_item.append_children([db_item])
- db_item.append_children(self._top_children())
-
- @staticmethod
- def _make_db_item(db_map):
- raise NotImplementedError()
- @staticmethod
- def _top_children():
+ def _make_db_item(self, db_map):
raise NotImplementedError()
@staticmethod
@@ -70,3 +66,8 @@ def db_item(item):
def db_row(self, item):
return self.db_item(item).child_number()
+
+ @Slot(QObject)
+ def _tear_down_tree(self, obj=None):
+ """Tears down tree items recursively"""
+ self._invisible_root_item.tear_down_recursively()
diff --git a/spinetoolbox/spine_db_editor/mvcmodels/utils.py b/spinetoolbox/spine_db_editor/mvcmodels/utils.py
index eab5e5985..4189bfa36 100644
--- a/spinetoolbox/spine_db_editor/mvcmodels/utils.py
+++ b/spinetoolbox/spine_db_editor/mvcmodels/utils.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -8,6 +9,7 @@
# 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 .
######################################################################################################################
+
"""General helper functions and classes for DB editor's models."""
import csv
from io import StringIO
diff --git a/spinetoolbox/spine_db_editor/scenario_generation.py b/spinetoolbox/spine_db_editor/scenario_generation.py
index f9fb8a262..1b6ca8920 100644
--- a/spinetoolbox/spine_db_editor/scenario_generation.py
+++ b/spinetoolbox/spine_db_editor/scenario_generation.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,9 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains functions for automatically generating scenarios from a set of alternatives.
-"""
+"""Contains functions for automatically generating scenarios from a set of alternatives."""
from itertools import compress, permutations
diff --git a/spinetoolbox/spine_db_editor/ui/__init__.py b/spinetoolbox/spine_db_editor/ui/__init__.py
index 5aad8885c..7bf75cecf 100644
--- a/spinetoolbox/spine_db_editor/ui/__init__.py
+++ b/spinetoolbox/spine_db_editor/ui/__init__.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
diff --git a/spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.py b/spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.py
new file mode 100644
index 000000000..cd6b79a97
--- /dev/null
+++ b/spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+######################################################################################################################
+# 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 .
+######################################################################################################################
+
+################################################################################
+## Form generated from reading UI file 'commit_viewer_affected_item_info.ui'
+##
+## Created by: Qt User Interface Compiler version 6.5.2
+##
+## WARNING! All changes made in this file will be lost when recompiling UI file!
+################################################################################
+
+from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
+ QMetaObject, QObject, QPoint, QRect,
+ QSize, QTime, QUrl, Qt)
+from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
+ QFont, QFontDatabase, QGradient, QIcon,
+ QImage, QKeySequence, QLinearGradient, QPainter,
+ QPalette, QPixmap, QRadialGradient, QTransform)
+from PySide6.QtWidgets import (QApplication, QHeaderView, QLabel, QSizePolicy,
+ QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget)
+
+class Ui_Form(object):
+ def setupUi(self, Form):
+ if not Form.objectName():
+ Form.setObjectName(u"Form")
+ Form.resize(400, 300)
+ self.verticalLayout = QVBoxLayout(Form)
+ self.verticalLayout.setObjectName(u"verticalLayout")
+ self.verticalLayout.setContentsMargins(0, 0, 0, 0)
+ self.affected_items_table = QTableWidget(Form)
+ self.affected_items_table.setObjectName(u"affected_items_table")
+
+ self.verticalLayout.addWidget(self.affected_items_table)
+
+ self.fetch_status_label = QLabel(Form)
+ self.fetch_status_label.setObjectName(u"fetch_status_label")
+
+ self.verticalLayout.addWidget(self.fetch_status_label)
+
+
+ self.retranslateUi(Form)
+
+ QMetaObject.connectSlotsByName(Form)
+ # setupUi
+
+ def retranslateUi(self, Form):
+ Form.setWindowTitle(QCoreApplication.translate("Form", u"Form", None))
+ self.fetch_status_label.setText(QCoreApplication.translate("Form", u"TextLabel", None))
+ # retranslateUi
+
diff --git a/spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.ui b/spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.ui
new file mode 100644
index 000000000..fe6cbacdc
--- /dev/null
+++ b/spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.ui
@@ -0,0 +1,56 @@
+
+
+
+ Form
+
+
+
+ 0
+ 0
+ 400
+ 300
+
+
+
+ Form
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+
+
+
+
+
+ TextLabel
+
+
+
+
+
+
+
+
diff --git a/spinetoolbox/spine_db_editor/ui/db_commit_viewer.py b/spinetoolbox/spine_db_editor/ui/db_commit_viewer.py
new file mode 100644
index 000000000..e3bdbbe7d
--- /dev/null
+++ b/spinetoolbox/spine_db_editor/ui/db_commit_viewer.py
@@ -0,0 +1,154 @@
+# -*- coding: utf-8 -*-
+######################################################################################################################
+# 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 .
+######################################################################################################################
+
+################################################################################
+## Form generated from reading UI file 'db_commit_viewer.ui'
+##
+## Created by: Qt User Interface Compiler version 6.5.2
+##
+## WARNING! All changes made in this file will be lost when recompiling UI file!
+################################################################################
+
+from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
+ QMetaObject, QObject, QPoint, QRect,
+ QSize, QTime, QUrl, Qt)
+from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
+ QFont, QFontDatabase, QGradient, QIcon,
+ QImage, QKeySequence, QLinearGradient, QPainter,
+ QPalette, QPixmap, QRadialGradient, QTransform)
+from PySide6.QtWidgets import (QApplication, QFrame, QHBoxLayout, QHeaderView,
+ QLabel, QSizePolicy, QSplitter, QStackedWidget,
+ QTabWidget, QTextBrowser, QTreeWidget, QTreeWidgetItem,
+ QVBoxLayout, QWidget)
+
+class Ui_DBCommitViewer(object):
+ def setupUi(self, DBCommitViewer):
+ if not DBCommitViewer.objectName():
+ DBCommitViewer.setObjectName(u"DBCommitViewer")
+ DBCommitViewer.resize(716, 218)
+ self.horizontalLayout_2 = QHBoxLayout(DBCommitViewer)
+ self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
+ self.splitter = QSplitter(DBCommitViewer)
+ self.splitter.setObjectName(u"splitter")
+ self.splitter.setOrientation(Qt.Horizontal)
+ self.commit_list = QTreeWidget(self.splitter)
+ __qtreewidgetitem = QTreeWidgetItem()
+ __qtreewidgetitem.setText(0, u"1");
+ self.commit_list.setHeaderItem(__qtreewidgetitem)
+ self.commit_list.setObjectName(u"commit_list")
+ self.splitter.addWidget(self.commit_list)
+ self.verticalFrame = QFrame(self.splitter)
+ self.verticalFrame.setObjectName(u"verticalFrame")
+ self.verticalFrame.setStyleSheet(u"QFrame {\n"
+" background-color: white;\n"
+"}")
+ self.verticalFrame.setFrameShape(QFrame.Box)
+ self.verticalLayout = QVBoxLayout(self.verticalFrame)
+ self.verticalLayout.setSpacing(3)
+ self.verticalLayout.setObjectName(u"verticalLayout")
+ self.verticalLayout.setContentsMargins(5, 5, 5, 0)
+ self.label = QLabel(self.verticalFrame)
+ self.label.setObjectName(u"label")
+
+ self.verticalLayout.addWidget(self.label)
+
+ self.affected_items_widget_stack = QStackedWidget(self.verticalFrame)
+ self.affected_items_widget_stack.setObjectName(u"affected_items_widget_stack")
+ self.items_page = QWidget()
+ self.items_page.setObjectName(u"items_page")
+ self.horizontalLayout_3 = QHBoxLayout(self.items_page)
+ self.horizontalLayout_3.setSpacing(0)
+ self.horizontalLayout_3.setObjectName(u"horizontalLayout_3")
+ self.horizontalLayout_3.setContentsMargins(0, 0, 0, 0)
+ self.affected_item_tab_widget = QTabWidget(self.items_page)
+ self.affected_item_tab_widget.setObjectName(u"affected_item_tab_widget")
+ self.affected_item_tab_widget.setDocumentMode(True)
+
+ self.horizontalLayout_3.addWidget(self.affected_item_tab_widget)
+
+ self.affected_items_widget_stack.addWidget(self.items_page)
+ self.no_items_page = QWidget()
+ self.no_items_page.setObjectName(u"no_items_page")
+ self.horizontalLayout = QHBoxLayout(self.no_items_page)
+ self.horizontalLayout.setSpacing(0)
+ self.horizontalLayout.setObjectName(u"horizontalLayout")
+ self.horizontalLayout.setContentsMargins(0, 0, 0, 0)
+ self.no_affected_items_notice = QTextBrowser(self.no_items_page)
+ self.no_affected_items_notice.setObjectName(u"no_affected_items_notice")
+ self.no_affected_items_notice.setFocusPolicy(Qt.NoFocus)
+ self.no_affected_items_notice.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ self.no_affected_items_notice.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ self.no_affected_items_notice.setOpenLinks(False)
+
+ self.horizontalLayout.addWidget(self.no_affected_items_notice)
+
+ self.affected_items_widget_stack.addWidget(self.no_items_page)
+ self.loading_page = QWidget()
+ self.loading_page.setObjectName(u"loading_page")
+ self.horizontalLayout_4 = QHBoxLayout(self.loading_page)
+ self.horizontalLayout_4.setSpacing(0)
+ self.horizontalLayout_4.setObjectName(u"horizontalLayout_4")
+ self.horizontalLayout_4.setContentsMargins(0, 0, 0, 0)
+ self.loading_label = QLabel(self.loading_page)
+ self.loading_label.setObjectName(u"loading_label")
+ self.loading_label.setAlignment(Qt.AlignLeading|Qt.AlignLeft|Qt.AlignTop)
+
+ self.horizontalLayout_4.addWidget(self.loading_label)
+
+ self.affected_items_widget_stack.addWidget(self.loading_page)
+ self.no_commit_selected_page = QWidget()
+ self.no_commit_selected_page.setObjectName(u"no_commit_selected_page")
+ self.verticalLayout_2 = QVBoxLayout(self.no_commit_selected_page)
+ self.verticalLayout_2.setSpacing(0)
+ self.verticalLayout_2.setObjectName(u"verticalLayout_2")
+ self.verticalLayout_2.setContentsMargins(0, 0, 0, 0)
+ self.label_2 = QLabel(self.no_commit_selected_page)
+ self.label_2.setObjectName(u"label_2")
+ self.label_2.setAlignment(Qt.AlignLeading|Qt.AlignLeft|Qt.AlignTop)
+
+ self.verticalLayout_2.addWidget(self.label_2)
+
+ self.affected_items_widget_stack.addWidget(self.no_commit_selected_page)
+
+ self.verticalLayout.addWidget(self.affected_items_widget_stack)
+
+ self.splitter.addWidget(self.verticalFrame)
+
+ self.horizontalLayout_2.addWidget(self.splitter)
+
+
+ self.retranslateUi(DBCommitViewer)
+
+ self.affected_items_widget_stack.setCurrentIndex(3)
+ self.affected_item_tab_widget.setCurrentIndex(-1)
+
+
+ QMetaObject.connectSlotsByName(DBCommitViewer)
+ # setupUi
+
+ def retranslateUi(self, DBCommitViewer):
+ self.label.setText(QCoreApplication.translate("DBCommitViewer", u"Affected items", None))
+ self.no_affected_items_notice.setHtml(QCoreApplication.translate("DBCommitViewer", u"\n"
+"\n"
+"
No affected items found for selected commit.
\n"
+"
Note, that it is not possible to show items that have been removed by this or a later commit.
Julia executable for Basic Console. Leave blank to use the Julia in your system PATH env. If Julia is not in your PATH, this line edit will be empty, indicating that Julia Tool Specs won't execute.
NOTE: This is the default setting for new Julia Tool specs. You can override this for each Tool spec separately.
", None))
#endif // QT_CONFIG(tooltip)
self.lineEdit_julia_path.setPlaceholderText(QCoreApplication.translate("SettingsForm", u"Using Julia executable in system path", None))
#if QT_CONFIG(tooltip)
self.toolButton_browse_julia.setToolTip(QCoreApplication.translate("SettingsForm", u"
Creates a Julia kernel for selected Julia executable and Julia project if it does not exist using the IJulia package. Selects a Julia kernel matching the selected Julia executable and Julia project if it already exists. May overwrite a Julia kernel if one for selected Julia executable already exists but the Julia project is different.
You can also create Julia kernels manually using the IJulia package.
Creates a Python kernel for selected Python interpreter if it does not exist using the ipykernel package. Selects a Python kernel matching the selected Python interpreter if it already exists.
You can also create Python kernels manually using the ipykernel package.
Checked: Whenever an object is included in the Entity graph, the graph automatically includes all its related N-D entities.
Unchecked: Whenever all the elements in a N-D entity are included in the Entity graph, the graph automatically includes it.
Note: This setting is a global default, but can be locally overriden in every Spine DB editor session.
", None))
#endif // QT_CONFIG(tooltip)
- self.checkBox_auto_expand_objects.setText(QCoreApplication.translate("SettingsForm", u"Auto-expand objects by default in Entity graph", None))
- self.checkBox_merge_dbs.setText(QCoreApplication.translate("SettingsForm", u"Merge databases by default in Entity graph", None))
+ self.checkBox_auto_expand_entities.setText(QCoreApplication.translate("SettingsForm", u"Auto-expand entities", None))
+ self.label_16.setText(QCoreApplication.translate("SettingsForm", u"Decay rate of attraction with distance", None))
+ self.checkBox_snap_entities.setText(QCoreApplication.translate("SettingsForm", u"Snap entities to grid", None))
+ self.checkBox_merge_dbs.setText(QCoreApplication.translate("SettingsForm", u"Merge databases", None))
+ self.label_3.setText(QCoreApplication.translate("SettingsForm", u"Max. entity dimension count", None))
self.groupBox.setTitle(QCoreApplication.translate("SettingsForm", u"Specification editors", None))
#if QT_CONFIG(tooltip)
self.checkBox_save_spec_before_closing.setToolTip(QCoreApplication.translate("SettingsForm", u"
Unchecked: Don't save specification and don't show message box
Partially checked: Show message box (default)
Checked: Save specification and don't show message box
", None))
diff --git a/spinetoolbox/ui/settings.ui b/spinetoolbox/ui/settings.ui
index 914a121ff..d82a99369 100644
--- a/spinetoolbox/ui/settings.ui
+++ b/spinetoolbox/ui/settings.ui
@@ -2,6 +2,7 @@
-
- Form
-
-
-
- 0
- 0
- 550
- 309
-
-
-
-
- 0
- 0
-
-
-
- Form
-
-
-
-
-
- <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd">
-<html><head><meta name="qrichtext" content="1" /><meta charset="utf-8" /><style type="text/css">
-p, li { white-space: pre-wrap; }
-hr { height: 1px; border-width: 0; }
-li.unchecked::marker { content: "\2610"; }
-li.checked::marker { content: "\2612"; }
-</style></head><body style=" font-family:'Segoe UI'; font-size:9pt; font-weight:400; font-style:normal;">
-<h1 style=" margin-top:18px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:xx-large; font-weight:700;">About Toolbox 0.8 update</span></h1>
-<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Spine Toolbox development team has been working hard on the 0.8 update since early 2023. As the release date gets close (or it may have passed already), we thought it might be worth giving you a heads-up on what the update is about since there will be major changes to the user interface as well as to Spine data structure.</p>
-<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">We have tried to make sure that your projects and databases are migrated to 0.8 as painlessly as possible. Still, <span style=" font-style:italic;">backup your projects and databases before the update</span>, as there may be bugs. We also maintain Spine-Database-API almost fully backward compatible. However, there are some changes that may break your scripts. Please report all issues to our <a href="https://github.com/spine-tools/Spine-Toolbox/issues"><span style=" text-decoration: underline; color:#0000ff;">issue tracker</span></a>.</p>
-<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Head to <a href="https://github.com/spine-tools/Spine-Toolbox/blob/0.8-dev/CHANGELOG.md"><span style=" text-decoration: underline; color:#0000ff;">CHANGELOG.md</span></a> to get familiar with the coming changes. If you are using Spine-Database-API in your scripts, it might be worth checking its <a href="https://github.com/spine-tools/Spine-Database-API/blob/0.8-dev/CHANGELOG.md"><span style=" text-decoration: underline; color:#0000ff;">CHANGELOG.md</span></a> as well.</p></body></html>
-
-
- true
-
-
-
-
-
-
- QDialogButtonBox::Close
-
-
-
-
-
-
-
-
diff --git a/spinetoolbox/ui_main.py b/spinetoolbox/ui_main.py
index b125f2623..405c44a92 100644
--- a/spinetoolbox/ui_main.py
+++ b/spinetoolbox/ui_main.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains ToolboxUI class.
-"""
-
+"""Contains a class for the main window of Spine Toolbox."""
import os
import sys
import locale
@@ -23,7 +21,6 @@
import numpy as np
from PySide6.QtCore import (
QByteArray,
- QItemSelection,
QMimeData,
QModelIndex,
QPoint,
@@ -34,7 +31,17 @@
QUrl,
QEvent,
)
-from PySide6.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QIcon, QCursor, QWindow, QAction, QUndoStack
+from PySide6.QtGui import (
+ QDesktopServices,
+ QGuiApplication,
+ QKeySequence,
+ QIcon,
+ QCursor,
+ QWindow,
+ QAction,
+ QUndoStack,
+ QColor,
+)
from PySide6.QtWidgets import (
QMainWindow,
QApplication,
@@ -53,11 +60,8 @@
QHBoxLayout,
)
from spine_engine.load_project_items import load_item_specification_factories
-from spine_items.category import CATEGORIES, CATEGORY_DESCRIPTIONS
from .project_item_icon import ProjectItemIcon
from .load_project_items import load_project_items
-from .mvcmodels.project_tree_item import CategoryProjectTreeItem, RootProjectTreeItem
-from .mvcmodels.project_item_model import ProjectItemModel
from .mvcmodels.project_item_specification_models import ProjectItemSpecificationModel, FilteredSpecificationModel
from .mvcmodels.filter_execution_model import FilterExecutionModel
from .project_settings import ProjectSettings
@@ -70,7 +74,7 @@
from .widgets.jupyter_console_widget import JupyterConsoleWidget
from .widgets.persistent_console_widget import PersistentConsoleWidget
from .widgets import toolbars
-from .widgets.open_project_widget import OpenProjectDialog
+from .widgets.open_project_dialog import OpenProjectDialog
from .widgets.jump_properties_widget import JumpPropertiesWidget
from .widgets.link_properties_widget import LinkPropertiesWidget
from .project import SpineToolboxProject
@@ -111,7 +115,6 @@
from .project_item.logging_connection import LoggingConnection, LoggingJump
from spinetoolbox.server.engine_client import EngineClient, RemoteEngineInitFailed, ClientSecurityModel
from .kernel_fetcher import KernelFetcher
-from .widgets.upgrade_notification import UpgradeNotificationDialog
class ToolboxUI(QMainWindow):
@@ -152,18 +155,16 @@ def __init__(self):
self.ui.graphicsView.set_ui(self)
self.key_press_filter = ChildCyclingKeyPressFilter(self)
self.ui.tabWidget_item_properties.installEventFilter(self.key_press_filter)
- self._share_item_edit_actions()
+ self._add_item_edit_actions()
self.ui.listView_console_executions.setModel(FilterExecutionModel(self))
# Set style sheets
self.setStyleSheet(MAINWINDOW_SS)
# Class variables
self.undo_stack = QUndoStack(self)
- self._item_categories = dict()
self._item_properties_uis = dict()
self.item_factories = dict() # maps item types to `ProjectItemFactory` objects
self._item_specification_factories = dict() # maps item types to `ProjectItemSpecificationFactory` objects
self._project = None
- self.project_item_model = None
self.specification_model = None
self.filtered_spec_factory_models = {}
self.show_datetime = self.update_datetime()
@@ -171,8 +172,7 @@ def __init__(self):
self.active_link_item = None
self._selected_item_names = set()
self.execution_in_progress = False
- self.sync_item_selection_with_scene = True
- self._anchor_callbacks = {"show upgrade notification dialog": self._show_upgrade_notification_dialog}
+ self._anchor_callbacks = {}
self.ui.textBrowser_eventlog.set_toolbox(self)
# DB manager
self.db_mngr = SpineDBManager(self._qsettings, self)
@@ -182,10 +182,9 @@ def __init__(self):
self.recent_projects_menu = RecentProjectsPopupMenu(self)
self.kernels_menu = KernelsPopupMenu(self)
# Make and initialize toolbars
- self.main_toolbar = toolbars.MainToolBar(
- self.ui.actionExecute_project, self.ui.actionExecute_selection, self.ui.actionStop_execution, self
- )
- self.addToolBar(Qt.TopToolBarArea, self.main_toolbar)
+ self.items_toolbar = toolbars.ItemsToolBar(self)
+ self.spec_toolbar = toolbars.SpecToolBar(self)
+ self.execute_toolbar = toolbars.ExecuteToolBar(self)
self._original_execute_project_action_tooltip = self.ui.actionExecute_project.toolTip()
self.setStatusBar(None)
# Additional consoles for item execution
@@ -208,15 +207,14 @@ def __init__(self):
self.set_debug_qactions()
# Finalize init
self.ui.tabWidget_item_properties.tabBar().hide() # Hide tab bar in properties dock widget
- self.restore_dock_widgets()
- self.restore_ui()
self.ui.listView_console_executions.hide()
self.ui.listView_console_executions.installEventFilter(self)
self.parse_project_item_modules()
- self.init_project_item_model()
self.init_specification_model()
self.make_item_properties_uis()
- self.main_toolbar.setup()
+ self.items_toolbar.setup()
+ self.spec_toolbar.setup()
+ self.execute_toolbar.setup()
self.link_properties_widgets = {
LoggingConnection: LinkPropertiesWidget(self, base_color=LINK_COLOR),
LoggingJump: JumpPropertiesWidget(self, base_color=JUMP_COLOR),
@@ -227,6 +225,9 @@ def __init__(self):
self.ui.tabWidget_item_properties.addTab(jump_tab, "Loop properties")
self._plugin_manager = PluginManager(self)
self._plugin_manager.load_installed_plugins()
+ self.refresh_toolbars()
+ self.restore_dock_widgets()
+ self.restore_ui()
self.set_work_directory()
self._disable_project_actions()
self.connect_signals()
@@ -307,33 +308,23 @@ def connect_signals(self):
self.ui.actionRemove.triggered.connect(self._remove_selected_items)
# Debug actions
self.show_properties_tabbar.triggered.connect(self.toggle_properties_tabbar_visibility)
- self.show_supported_img_formats.triggered.connect(supported_img_formats) # in helpers.py
- # Context-menus
- self.ui.treeView_project.customContextMenuRequested.connect(self.show_item_context_menu)
+ self.show_supported_img_formats.triggered.connect(supported_img_formats)
# Undo stack
self.undo_stack.cleanChanged.connect(self.update_window_modified)
# Views
self.ui.listView_console_executions.selectionModel().currentChanged.connect(self._select_console_execution)
self.ui.listView_console_executions.model().layoutChanged.connect(self._refresh_console_execution_list)
- self.ui.treeView_project.selectionModel().selectionChanged.connect(self.item_selection_changed)
- # Models
- self.project_item_model.rowsInserted.connect(self._update_execute_enabled)
- self.project_item_model.rowsRemoved.connect(self._update_execute_enabled)
# Execution
self.ui.actionExecute_project.triggered.connect(self._execute_project)
self.ui.actionExecute_selection.triggered.connect(self._execute_selection)
self.ui.actionStop_execution.triggered.connect(self._stop_execution)
# Open dir
- self._button_item_dir.clicked.connect(self._open_active_item_dir)
+ self._button_item_dir.clicked.connect(self._open_project_item_directory)
# Consoles
self.jupyter_console_requested.connect(self._setup_jupyter_console)
self.kernel_shutdown.connect(self._handle_kernel_shutdown)
self.persistent_console_requested.connect(self._setup_persistent_console, Qt.BlockingQueuedConnection)
- @Slot(bool)
- def _open_active_item_dir(self, _checked=False):
- self.active_project_item.open_directory()
-
@staticmethod
def set_error_mode():
"""Sets Windows error mode to show all error dialog boxes from subprocesses.
@@ -360,24 +351,22 @@ def _update_qsettings(self):
try:
old_value = int(self._qsettings.value("appSettings/saveAtExit"))
except ValueError:
- # Old value is already of correct form.
+ # Old value is already of correct form
pass
else:
new_value = {0: "prompt", 1: "prompt", 2: "automatic"}[old_value]
self._qsettings.setValue("appSettings/saveAtExit", new_value)
def _update_execute_enabled(self):
- first_index = next(self.project_item_model.leaf_indexes(), None)
enabled_by_project = self._project.settings.enable_execute_all if self._project is not None else False
- self.ui.actionExecute_project.setEnabled(
- enabled_by_project and first_index is not None and not self.execution_in_progress
- )
+ self.ui.actionExecute_project.setEnabled(enabled_by_project and not self.execution_in_progress)
if not enabled_by_project:
self.ui.actionExecute_project.setToolTip("Executing entire project disabled by project settings.")
else:
self.ui.actionExecute_project.setToolTip(self._original_execute_project_action_tooltip)
def _update_execute_selected_enabled(self):
+ """Enables or disables execute selected action based on the number of selected items."""
has_selection = bool(self._selected_item_names)
self.ui.actionExecute_selection.setEnabled(has_selection and not self.execution_in_progress)
@@ -389,11 +378,11 @@ def update_window_modified(self, clean):
def parse_project_item_modules(self):
"""Collects data from project item factories."""
- self._item_categories, self.item_factories = load_project_items("spine_items")
+ self.item_factories = load_project_items("spine_items")
self._item_specification_factories = load_item_specification_factories("spine_items")
def set_work_directory(self, new_work_dir=None):
- """Creates a work directory if it does not exist or changes the current work directory to given one.
+ """Creates a work directory if it does not exist or changes the current work directory to given.
Args:
new_work_dir (str, optional): If given, changes the work directory to given
@@ -459,7 +448,6 @@ def init_project(self, project_dir):
welcome_msg = "Welcome to Spine Toolbox! If you need help, please read the {0} guide.".format(
getting_started_anchor
)
- self._notify_about_upgrades_at_startup()
if not project_dir:
open_previous_project = int(self._qsettings.value("appSettings/openPreviousProject", defaultValue="0"))
if (
@@ -522,6 +510,7 @@ def create_project(self, proj_dir):
if self._project is not None:
if not self.close_project():
return
+ self.ui.textBrowser_eventlog.clear()
self.undo_stack.clear()
self._project = SpineToolboxProject(
self,
@@ -531,7 +520,6 @@ def create_project(self, proj_dir):
settings=ProjectSettings(),
logger=self,
)
- self.project_item_model.connect_to_project(self._project)
self.specification_model.connect_to_project(self._project)
self._enable_project_actions()
self.ui.actionSave.setDisabled(True) # Disable in a clean project
@@ -574,7 +562,14 @@ def open_project(self, load_dir=None):
load_dir = QFileDialog.getExistingDirectory(self, caption="Open Spine Toolbox Project", dir=start_dir)
if not load_dir:
return False # Cancelled
- return self.restore_project(load_dir)
+ if not self.restore_project(load_dir):
+ if not self.undo_stack.isClean(): # If current project not saved, don't exit it
+ self.msg_warning.emit(f"Cancelled opening project {load_dir}. Current project has unsaved changes.")
+ return False
+ # If opening project failed, don't clear the event log so that the error message is visible
+ self.close_project(ask_confirmation=False, clear_event_log=False)
+ return False
+ return True
def restore_project(self, project_dir, ask_confirmation=True):
"""Initializes UI, Creates project, models, connections, etc., when opening a project.
@@ -598,7 +593,6 @@ def restore_project(self, project_dir, ask_confirmation=True):
settings=ProjectSettings(),
logger=self,
)
- self.project_item_model.connect_to_project(self._project)
self.specification_model.connect_to_project(self._project)
self._enable_project_actions()
self.ui.actionSave.setDisabled(True) # Save is disabled in a clean project
@@ -610,7 +604,6 @@ def restore_project(self, project_dir, ask_confirmation=True):
self.remove_path_from_recent_projects(self._project.project_dir)
return False
self._plugin_manager.reload_plugins_with_local_data()
- self.ui.treeView_project.expandAll()
# Reset zoom on Design View
self.ui.graphicsView.reset_zoom()
self.update_recent_projects()
@@ -619,9 +612,14 @@ def restore_project(self, project_dir, ask_confirmation=True):
def _toolbars(self):
"""Yields all toolbars in the window."""
- yield self.main_toolbar
+ yield self.items_toolbar
+ yield self.spec_toolbar
yield from self._plugin_manager.plugin_toolbars.values()
+ def set_toolbar_colored_icons(self, checked):
+ for toolbar in self._toolbars():
+ toolbar.set_colored_icons(checked)
+
def _disable_project_actions(self):
"""Disables all project-related actions, except
New project, Open project and Open recent. Called
@@ -650,11 +648,16 @@ def _enable_project_actions(self):
self._unset_execution_in_progress()
def refresh_toolbars(self):
- """Set toolbars' color using highest possible contrast."""
+ """Set toolbars' color using the highest possible contrast."""
all_toolbars = list(self._toolbars())
for k, toolbar in enumerate(all_toolbars):
color = color_from_index(k, len(all_toolbars), base_hue=217.0, saturation=0.6)
toolbar.set_color(color)
+ if self.toolBarArea(toolbar) == Qt.NoToolBarArea:
+ self.addToolBar(Qt.TopToolBarArea, toolbar)
+ self.execute_toolbar.set_color(QColor("silver"))
+ if self.toolBarArea(self.execute_toolbar) == Qt.NoToolBarArea:
+ self.addToolBar(Qt.TopToolBarArea, self.execute_toolbar)
@Slot()
def show_recent_projects_menu(self):
@@ -732,11 +735,12 @@ def save_project_as(self):
# noinspection PyCallByClass, PyArgumentList
QMessageBox.information(self, f"Project {self._project.name} saved", f"Project directory is now\n\n{answer}")
- def close_project(self, ask_confirmation=True):
+ def close_project(self, ask_confirmation=True, clear_event_log=True):
"""Closes the current project.
Args:
ask_confirmation (bool): if False, no confirmation whatsoever is asked from user
+ clear_event_log (bool): if True, the event log is cleared after closing the project
Returns:
bool: True when no project open or when it's closed successfully, False otherwise.
@@ -755,9 +759,10 @@ def close_project(self, ask_confirmation=True):
self._project.tear_down()
self._project = None
self._disable_project_actions()
- self.undo_stack.setClean()
+ self.undo_stack.clear()
self.update_window_title()
- self.ui.textBrowser_eventlog.clear()
+ if clear_event_log:
+ self.ui.textBrowser_eventlog.clear()
return True
@Slot(bool)
@@ -768,15 +773,6 @@ def set_project_description(self, _=False):
dialog = SetDescriptionDialog(self, self._project)
dialog.show()
- def init_project_item_model(self):
- """Initializes project item model. Create root and category items and add them to the model."""
- root_item = RootProjectTreeItem()
- self.project_item_model = ProjectItemModel(root_item, self)
- for category in CATEGORIES:
- category_item = CategoryProjectTreeItem(str(category), CATEGORY_DESCRIPTIONS[category])
- self.project_item_model.insert_item(category_item)
- self.ui.treeView_project.setModel(self.project_item_model)
-
def init_specification_model(self):
"""Initializes specification model."""
factory_icons = {item_type: QIcon(factory.icon()) for item_type, factory in self.item_factories.items()}
@@ -805,16 +801,15 @@ def _make_properties_tab(self, properties_ui):
layout.addWidget(properties_ui)
return tab
- def add_project_items(self, items_dict, silent=False):
+ def add_project_items(self, items_dict):
"""Pushes an AddProjectItemsCommand to the undo stack.
Args:
items_dict (dict): mapping from item name to item dictionary
- silent (bool): if True, suppress log messages
"""
if self._project is None or not items_dict:
return
- self.undo_stack.push(AddProjectItemsCommand(self._project, items_dict, self.item_factories, silent))
+ self.undo_stack.push(AddProjectItemsCommand(self._project, items_dict, self.item_factories))
def supports_specifications(self, item_type):
"""Returns True if given project item type supports specifications.
@@ -939,22 +934,6 @@ def overwrite_check(self, project_dir):
return False
return True
- @Slot(QItemSelection, QItemSelection)
- def item_selection_changed(self, selected, deselected):
- """Synchronizes selection with scene. The scene handles item/link de/activation."""
- inds = self.ui.treeView_project.selectedIndexes()
- self._selected_item_names = {
- self.project_item_model.item(i).name for i in self.ui.treeView_project.selectedIndexes()
- }
- self._update_execute_selected_enabled()
- if not self.sync_item_selection_with_scene:
- return
- project_items = [self.project_item_model.item(i).project_item for i in inds]
- project_item_names = {i.name for i in project_items}
- scene = self.ui.graphicsView.scene()
- for icon in scene.project_item_icons():
- icon.setSelected(icon.name() in project_item_names)
-
def refresh_active_elements(self, active_project_item, active_link_item, selected_item_names):
self._selected_item_names = selected_item_names
self._update_execute_selected_enabled()
@@ -975,9 +954,10 @@ def _activate_properties_tab(self):
self.activate_no_selection_tab()
def _set_active_project_item(self, active_project_item):
- """
+ """Activates given project item.
+
Args:
- active_project_item (ProjectItemBase or NoneType)
+ active_project_item (ProjectItemBase or NoneType): Active project item
"""
if self.active_project_item == active_project_item:
return
@@ -994,11 +974,10 @@ def _set_active_project_item(self, active_project_item):
self._item_properties_uis[self.active_project_item.item_type()].set_item(self.active_project_item)
def _set_active_link_item(self, active_link_item):
- """
- Sets active link and connects to corresponding properties widget.
+ """Activates given link and connects it to the corresponding Properties widget.
Args:
- active_link_item (LoggingConnection or LoggingJump, optional)
+ active_link_item (LoggingConnection or LoggingJump, optional): Active link
"""
if self.active_link_item is active_link_item:
return
@@ -1052,6 +1031,7 @@ def update_properties_ui(self):
widget.repaint()
def _get_active_properties_widget(self):
+ """Returns the active item's or link's properties widget or None if no item or link is active."""
if self.active_project_item is not None:
return self._item_properties_uis[self.active_project_item.item_type()]
if self.active_link_item is not None:
@@ -1100,7 +1080,7 @@ def repair_specification(self, name):
"""Repairs specification if it is broken.
Args:
- name (str): specification's name
+ name (str): Specification's name
"""
specification = self._project.get_specification(name)
item_factory = self.item_factories.get(specification.item_type)
@@ -1111,12 +1091,12 @@ def prompt_save_location(self, title, proposed_path, file_filter):
"""Shows a dialog for the user to select a path to save a file.
Args:
- title (str): dialog window title
- proposed_path (str): A proposed location.
- file_filter (str): file extension filter
+ title (str): Dialog window title
+ proposed_path (str): Proposed location.
+ file_filter (str): File extension filter
Returns:
- str: absolute path or None if dialog was cancelled
+ str: Absolute path or None if dialog was cancelled
"""
answer = QFileDialog.getSaveFileName(self, title, proposed_path, file_filter)
if not answer[0]: # Cancel button clicked
@@ -1129,8 +1109,8 @@ def _log_specification_saved(self, name, path):
together with a clickable link to change the location.
Args:
- name (str): specification's name
- path (str): specification's file path
+ name (str): Specification's name
+ path (str): Specification's file path
"""
self.msg_success.emit(
f"Specification {name} successfully saved as "
@@ -1168,8 +1148,8 @@ def register_anchor_callback(self, url, callback):
Used by ``ToolFactory.repair_specification()``.
Args:
- url (str): The anchor url
- callback (function): A function to call when the anchor is clicked on event log.
+ url (str): Anchor url
+ callback (function): Function to call when the anchor is clicked
"""
self._anchor_callbacks[url] = callback
@@ -1203,7 +1183,7 @@ def _change_specification_file_location(self, name):
otherwise tries to find the specification from the plugin manager.
Args:
- name (str): specification's name
+ name (str): Specification's name
"""
if self._project is not None:
if not self._project.is_specification_name_reserved(name):
@@ -1262,14 +1242,14 @@ def edit_specification(self, index, item):
index (QModelIndex): Index of the item (from double-click or context menu signal)
item (ProjectItem, optional)
"""
- if not index.isValid() or not item:
+ if not index.isValid():
return
specification = self.specification_model.specification(index.row())
# Open spec in Tool specification edit widget
- if item.item_type() == "Importer":
+ if item and item.item_type() == "Importer":
item.edit_specification()
- else:
- self.show_specification_form(specification.item_type, specification, item)
+ return
+ self.show_specification_form(specification.item_type, specification, item)
@Slot(QModelIndex)
def remove_specification(self, index):
@@ -1359,17 +1339,15 @@ def restore_dock_widgets(self):
dock.setMinimumSize(0, 0)
dock.setVisible(True)
dock.setFloating(False)
- self.splitDockWidget(self.ui.dockWidget_project, self.ui.dockWidget_eventlog, Qt.Orientation.Vertical)
self.splitDockWidget(self.ui.dockWidget_eventlog, self.ui.dockWidget_console, Qt.Orientation.Horizontal)
self.ui.dockWidget_eventlog.raise_()
- self.splitDockWidget(self.ui.dockWidget_project, self.ui.dockWidget_design_view, Qt.Orientation.Horizontal)
self.splitDockWidget(self.ui.dockWidget_design_view, self.ui.dockWidget_item, Qt.Orientation.Horizontal)
- docks = (self.ui.dockWidget_project, self.ui.dockWidget_design_view, self.ui.dockWidget_item)
- width = sum(d.size().width() for d in docks)
- self.resizeDocks(docks, [0.2 * width, 0.5 * width, 0.3 * width], Qt.Orientation.Horizontal)
- docks = (self.ui.dockWidget_project, self.ui.dockWidget_eventlog)
- width = sum(d.size().width() for d in docks)
- self.resizeDocks(docks, [0.6 * width, 0.4 * width], Qt.Orientation.Vertical)
+ top_docks = (self.ui.dockWidget_design_view, self.ui.dockWidget_item)
+ width = sum(d.size().width() for d in top_docks)
+ self.resizeDocks(top_docks, [0.7 * width, 0.3 * width], Qt.Orientation.Horizontal)
+ bottom_docks = (self.ui.dockWidget_eventlog, self.ui.dockWidget_console)
+ # bottom_width = sum(d.size().width() for d in bottom_docks)
+ self.resizeDocks(bottom_docks, [0.5 * width, 0.5 * width], Qt.Orientation.Horizontal)
def _add_execute_actions(self):
"""Adds execution handler actions to the main window."""
@@ -1379,16 +1357,17 @@ def _add_execute_actions(self):
def set_debug_qactions(self):
"""Sets shortcuts for QActions that may be needed in debugging."""
- self.show_properties_tabbar.setShortcut(QKeySequence(Qt.CTRL | Qt.Key_0))
- self.show_supported_img_formats.setShortcut(QKeySequence(Qt.CTRL | Qt.Key_8))
+ self.show_properties_tabbar.setShortcut(QKeySequence(Qt.Modifier.CTRL.value | Qt.Key.Key_0.value))
+ self.show_supported_img_formats.setShortcut(QKeySequence(Qt.Modifier.CTRL.value | Qt.Key.Key_8.value))
self.addAction(self.show_properties_tabbar)
self.addAction(self.show_supported_img_formats)
def add_menu_actions(self):
"""Adds extra actions to Edit and View menu."""
- self.ui.menuToolbars.addAction(self.main_toolbar.toggleViewAction())
+ self.ui.menuToolbars.addAction(self.items_toolbar.toggleViewAction())
+ self.ui.menuToolbars.addAction(self.spec_toolbar.toggleViewAction())
+ self.ui.menuToolbars.addAction(self.execute_toolbar.toggleViewAction())
self.ui.menuDock_Widgets.addAction(self.ui.dockWidget_design_view.toggleViewAction())
- self.ui.menuDock_Widgets.addAction(self.ui.dockWidget_project.toggleViewAction())
self.ui.menuDock_Widgets.addAction(self.ui.dockWidget_eventlog.toggleViewAction())
self.ui.menuDock_Widgets.addAction(self.ui.dockWidget_item.toggleViewAction())
self.ui.menuDock_Widgets.addAction(self.ui.dockWidget_console.toggleViewAction())
@@ -1418,7 +1397,7 @@ def update_datetime(self):
@Slot(str)
def add_message(self, msg):
- """Append regular message to Event Log.
+ """Appends a regular message to the Event Log.
Args:
msg (str): String written to QTextBrowser
@@ -1428,7 +1407,7 @@ def add_message(self, msg):
@Slot(str)
def add_success_message(self, msg):
- """Append message with green text color to Event Log.
+ """Appends a message with green text to the Event Log.
Args:
msg (str): String written to QTextBrowser
@@ -1438,7 +1417,7 @@ def add_success_message(self, msg):
@Slot(str)
def add_error_message(self, msg):
- """Append message with red color to Event Log.
+ """Appends a message with red color to the Event Log.
Args:
msg (str): String written to QTextBrowser
@@ -1448,7 +1427,7 @@ def add_error_message(self, msg):
@Slot(str)
def add_warning_message(self, msg):
- """Append message with yellow (golden) color to Event Log.
+ """Appends a message with yellow (golden) color to the Event Log.
Args:
msg (str): String written to QTextBrowser
@@ -1458,7 +1437,7 @@ def add_warning_message(self, msg):
@Slot(str)
def add_process_message(self, msg):
- """Writes message from stdout to process output QTextBrowser.
+ """Writes message from stdout to the Event Log.
Args:
msg (str): String written to QTextBrowser
@@ -1468,7 +1447,7 @@ def add_process_message(self, msg):
@Slot(str)
def add_process_error_message(self, msg):
- """Writes message from stderr to process output QTextBrowser.
+ """Writes message from stderr to the Event Log.
Args:
msg (str): String written to QTextBrowser
@@ -1564,11 +1543,10 @@ def show_add_project_item_form(self, item_type, x=0, y=0, spec=""):
self.add_project_item_form.show()
def supports_specification(self, item_type):
- """
- Returns True if given item type supports specifications.
+ """Returns True if given item type supports specifications.
Args:
- item_type (str): item's type
+ item_type (str): Item's type
Returns:
bool: True if item supports specifications, False otherwise
@@ -1577,24 +1555,19 @@ def supports_specification(self, item_type):
@Slot()
def show_specification_form(self, item_type, specification=None, item=None, **kwargs):
- """
- Shows specification widget.
+ """Shows specification widget.
Args:
- item_type (str): item's type
- specification (ProjectItemSpecification, optional): specification
- item (ProjectItem, optional): project item
- **kwargs: parameters passed to the specification widget
+ item_type (str): Item's type
+ specification (ProjectItemSpecification, optional): Specification
+ item (ProjectItem, optional): Project item
+ **kwargs: Parameters passed to the specification widget
"""
if not self._project:
self.msg.emit("Please open or create a project first")
return
if not self.supports_specification(item_type):
return
- msg = f"Opening {item_type} specification editor"
- if specification:
- msg += f" for {specification.name}"
- self.msg.emit(msg)
multi_tab_editor = next(self.get_all_multi_tab_spec_editors(item_type), None)
if multi_tab_editor is None:
multi_tab_editor = MultiTabSpecEditor(self, item_type)
@@ -1629,20 +1602,20 @@ def _get_existing_spec_editor(self, item_type, specification, item):
@Slot()
def show_settings(self):
- """Show Settings widget."""
+ """Shows the Settings widget."""
self.settings_form = SettingsWidget(self)
self.settings_form.show()
@Slot()
def show_about(self):
- """Show About Spine Toolbox form."""
+ """Shows the About Spine Toolbox widget."""
form = AboutWidget(self)
form.show()
# pylint: disable=no-self-use
@Slot()
def show_user_guide(self):
- """Open Spine Toolbox documentation index page in browser."""
+ """Opens Spine Toolbox documentation index page in browser."""
index_url = f"{ONLINE_DOCUMENTATION_URL}/index.html"
# noinspection PyTypeChecker, PyCallByClass, PyArgumentList
open_url(index_url)
@@ -1650,7 +1623,7 @@ def show_user_guide(self):
# pylint: disable=no-self-use
@Slot()
def show_getting_started_guide(self):
- """Open Spine Toolbox Getting Started HTML page in browser."""
+ """Opens Spine Toolbox Getting Started HTML page in browser."""
index_url = f"{ONLINE_DOCUMENTATION_URL}/getting_started.html"
# noinspection PyTypeChecker, PyCallByClass, PyArgumentList
open_url(index_url)
@@ -1729,36 +1702,21 @@ def engine_server_settings(self):
)
return host, port, sec_model, sec_folder
- @Slot(QPoint)
- def show_item_context_menu(self, pos):
- """Context menu for project items listed in the project QTreeView.
-
- Args:
- pos (QPoint): Mouse position
- """
- ind = self.ui.treeView_project.indexAt(pos)
- global_pos = self.ui.treeView_project.viewport().mapToGlobal(pos)
- self.show_project_or_item_context_menu(global_pos, ind)
-
- def show_project_or_item_context_menu(self, pos, index):
+ def show_project_or_item_context_menu(self, pos, item):
"""Creates and shows the project item context menu.
Args:
pos (QPoint): Mouse position
- index (QModelIndex, optional): Index of concerned item or None
+ item (ProjectItem, optional): Project item or None
"""
- if not index: # Clicked on a blank area in Design view
+ if not item: # Clicked on a blank area in Design view
menu = QMenu(self)
menu.addAction(self.ui.actionPaste)
menu.addAction(self.ui.actionPasteAndDuplicateFiles)
menu.addSeparator()
menu.addAction(self.ui.actionOpen_project_directory)
- elif not index.isValid(): # Clicked on a blank area in Project tree view
- menu = QMenu(self)
- menu.addAction(self.ui.actionOpen_project_directory)
- else: # Clicked on an item, show the custom context menu for that item
- item = self.project_item_model.item(index)
- menu = item.custom_context_menu(self)
+ else: # Clicked on an item, show the context menu for that item
+ menu = self.project_item_context_menu(item.actions())
menu.setToolTipsVisible(True)
menu.aboutToShow.connect(self.refresh_edit_action_states)
menu.aboutToHide.connect(self.enable_edit_actions)
@@ -1766,11 +1724,11 @@ def show_project_or_item_context_menu(self, pos, index):
menu.deleteLater()
def show_link_context_menu(self, pos, link):
- """Context menu for connection links.
+ """Shows the Context menu for connection links.
Args:
pos (QPoint): Mouse position
- link (Link(QGraphicsPathItem)): The concerned link
+ link (Link(QGraphicsPathItem)): The link in question
"""
menu = QMenu(self)
menu.addAction(self.ui.actionRemove)
@@ -1779,36 +1737,56 @@ def show_link_context_menu(self, pos, link):
action = menu.exec(pos)
if action is self.ui.actionTake_link:
self.ui.graphicsView.take_link(link)
- self.refresh_edit_action_states()
+ self.refresh_edit_action_states() # Disables actionRemove because take_link clears selections
+ self.ui.actionRemove.setEnabled(True)
menu.deleteLater()
@Slot()
def refresh_edit_action_states(self):
"""Sets the enabled/disabled state for copy, paste, duplicate,
- and remove actions in File-Edit menu, project tree view
+ remove and rename actions in File-Edit menu, project tree view
context menu, and in Design View context menus just before the
menus are shown to user."""
+ if not self.project():
+ self.disable_edit_actions()
+ return
clipboard = QApplication.clipboard()
byte_data = clipboard.mimeData().data("application/vnd.spinetoolbox.ProjectItem")
can_paste = not byte_data.isNull()
selected_items = self.ui.graphicsView.scene().selectedItems()
- has_selection = bool(selected_items)
can_copy = any(isinstance(x, ProjectItemIcon) for x in selected_items)
- has_items = self.project_item_model.n_items() > 0
+ has_items = self.project().n_items > 0
selected_project_items = [x for x in selected_items if isinstance(x, ProjectItemIcon)]
- _methods = [
- getattr(self.project_item_model.get_item(x.name()).project_item, "copy_local_data")
- for x in selected_project_items
- ]
+ _methods = [getattr(self.project().get_item(x.name()), "copy_local_data") for x in selected_project_items]
can_duplicate_files = any(m.__qualname__.partition(".")[0] != "ProjectItem" for m in _methods)
+ # Renaming an item should always be allowed except when it's a Data Store that is open in an editor
+ for item in (self.project().get_item(x.name()) for x in selected_project_items):
+ if item.item_type() == "Data Store" and item.has_listeners():
+ self.ui.actionRename_item.setEnabled(False)
+ self.ui.actionRename_item.setToolTip(
+ f"
The editor for {item.name} needs to be closed before renaming.
"
+ )
+ else:
+ self.ui.actionRename_item.setEnabled(True)
+ self.ui.actionRename_item.setToolTip("Rename project item")
self.ui.actionCopy.setEnabled(can_copy)
self.ui.actionPaste.setEnabled(can_paste)
self.ui.actionPasteAndDuplicateFiles.setEnabled(can_paste)
self.ui.actionDuplicate.setEnabled(can_copy)
self.ui.actionDuplicateAndDuplicateFiles.setEnabled(can_duplicate_files)
- self.ui.actionRemove.setEnabled(has_selection)
+ self.ui.actionRemove.setEnabled(bool(selected_items))
self.ui.actionRemove_all.setEnabled(has_items)
+ def disable_edit_actions(self):
+ """Disables edit actions."""
+ self.ui.actionCopy.setEnabled(False)
+ self.ui.actionPaste.setEnabled(False)
+ self.ui.actionPasteAndDuplicateFiles.setEnabled(False)
+ self.ui.actionDuplicate.setEnabled(False)
+ self.ui.actionDuplicateAndDuplicateFiles.setEnabled(False)
+ self.ui.actionRemove.setEnabled(False)
+ self.ui.actionRemove_all.setEnabled(False)
+
@Slot()
def enable_edit_actions(self):
"""Enables project item edit actions after a QMenu has been shown.
@@ -1819,10 +1797,10 @@ def enable_edit_actions(self):
self.ui.actionPasteAndDuplicateFiles.setEnabled(True)
self.ui.actionDuplicate.setEnabled(True)
self.ui.actionDuplicateAndDuplicateFiles.setEnabled(True)
+ self.ui.actionRemove.setEnabled(True)
def _tasks_before_exit(self):
- """
- Returns a list of tasks to perform before exiting the application.
+ """Returns a list of tasks to perform before exiting the application.
Possible tasks are:
@@ -1831,7 +1809,7 @@ def _tasks_before_exit(self):
- `"save"`: save project before quitting
Returns:
- a list containing zero or more tasks
+ list: Zero or more tasks in a list
"""
save_at_exit = (
self._qsettings.value("appSettings/saveAtExit", defaultValue="prompt")
@@ -1849,11 +1827,10 @@ def _tasks_before_exit(self):
return tasks
def _perform_pre_exit_tasks(self):
- """
- Prompts user to confirm quitting and saves the project if necessary.
+ """Prompts user to confirm quitting and saves the project if necessary.
Returns:
- True if exit should proceed, False if the process was cancelled
+ bool: True if exit should proceed, False if the process was cancelled
"""
tasks = self._tasks_before_exit()
for task in tasks:
@@ -1868,11 +1845,10 @@ def _perform_pre_exit_tasks(self):
return True
def _confirm_exit(self):
- """
- Confirms exiting from user.
+ """Confirms exiting from user.
Returns:
- True if exit should proceed, False if user cancelled
+ bool: True if exit should proceed, False if user cancelled
"""
msg = QMessageBox(parent=self)
msg.setIcon(QMessageBox.Icon.Question)
@@ -1895,11 +1871,10 @@ def _confirm_exit(self):
return False
def _confirm_project_close(self):
- """
- Confirms exit from user and saves the project if requested.
+ """Confirms exit from user and saves the project if requested.
Returns:
- True if exiting should proceed, False if user cancelled
+ bool: True if exiting should proceed, False if user cancelled
"""
msg = QMessageBox(parent=self)
msg.setIcon(QMessageBox.Icon.Question)
@@ -1999,7 +1974,7 @@ def closeEvent(self, event):
else:
self._qsettings.setValue("appSettings/previousProject", self._project.project_dir)
self.update_recent_projects()
- self._qsettings.setValue("appSettings/toolbarIconOrdering", self.main_toolbar.icon_ordering())
+ self._qsettings.setValue("appSettings/toolbarIconOrdering", self.items_toolbar.icon_ordering())
self._qsettings.setValue("mainWindow/windowSize", self.size())
self._qsettings.setValue("mainWindow/windowPosition", self.pos())
self._qsettings.setValue("mainWindow/windowState", self.saveState(version=1))
@@ -2017,8 +1992,7 @@ def closeEvent(self, event):
event.accept()
def _serialize_selected_items(self):
- """
- Serializes selected project items into a dictionary.
+ """Serializes selected project items into a dictionary.
The serialization protocol tries to imitate the format in which projects are saved.
@@ -2031,8 +2005,7 @@ def _serialize_selected_items(self):
if not isinstance(item_icon, ProjectItemIcon):
continue
name = item_icon.name()
- index = self.project_item_model.find_item(name)
- project_item = self.project_item_model.item(index).project_item
+ project_item = self.project().get_item(name)
item_dict = dict(project_item.item_dict())
item_dict["original_data_dir"] = project_item.data_dir
item_dict["original_db_url"] = item_dict.get("url")
@@ -2040,15 +2013,15 @@ def _serialize_selected_items(self):
return items_dict
def _deserialized_item_position_shifts(self, item_dicts):
- """
- Calculates horizontal and vertical shifts for project items being deserialized.
+ """Calculates horizontal and vertical shifts for project items being deserialized.
If the mouse cursor is on the Design view we try to place the items unders the cursor.
- Otherwise the items will get a small shift so they don't overlap a possible item below.
+ Otherwise, the items will get a small shift, so they don't overlap a possible item below.
In case the items don't fit the scene rect we clamp their coordinates within it.
Args:
- item_dicts (dict): a dictionary of serialized items being deserialized
+ item_dicts (dict): Dictionary of serialized items being deserialized
+
Returns:
tuple: a tuple of (horizontal shift, vertical shift) in scene's coordinates
"""
@@ -2077,11 +2050,10 @@ def _set_deserialized_item_position(item_dict, shift_x, shift_y, scene_rect):
item_dict["y"] = new_y
def _deserialize_items(self, items_dict, duplicate_files=False):
- """
- Deserializes project items from a dictionary and adds them to the current project.
+ """Deserializes project items from a dictionary and adds them to the current project.
Args:
- items_dict (dict): serialized project items
+ items_dict (dict): Serialized project items
"""
if self._project is None:
return
@@ -2092,13 +2064,13 @@ def _deserialize_items(self, items_dict, duplicate_files=False):
final_items_dict = dict()
for name, item_dict in items_dict.items():
item_dict["duplicate_files"] = duplicate_files
- if self.project_item_model.find_item(name) is not None:
+ if name in self.project().all_item_names:
new_name = unique_name(name, self.project().all_item_names)
final_items_dict[new_name] = item_dict
else:
final_items_dict[name] = item_dict
self._set_deserialized_item_position(item_dict, shift_x, shift_y, scene_rect)
- self.add_project_items(final_items_dict, silent=True)
+ self.add_project_items(final_items_dict)
@Slot()
def project_item_to_clipboard(self):
@@ -2136,8 +2108,8 @@ def duplicate_project_item(self, duplicate_files=False):
return
self._deserialize_items(item_dicts, duplicate_files)
- def _share_item_edit_actions(self):
- """Adds generic actions to project tree view and Design View."""
+ def _add_item_edit_actions(self):
+ """Adds generic actions to Design View."""
actions = [
self.ui.actionCopy,
self.ui.actionPaste,
@@ -2148,7 +2120,6 @@ def _share_item_edit_actions(self):
]
for action in actions:
action.setShortcutContext(Qt.WidgetShortcut)
- self.ui.treeView_project.addAction(action)
self.ui.graphicsView.addAction(action)
@Slot(str, str)
@@ -2158,6 +2129,7 @@ def _show_message_box(self, title, message):
@Slot(str, str)
def _show_error_box(self, title, message):
+ """Shows an error message with the given title and message."""
box = QErrorMessage(self)
box.setWindowTitle(title)
box.setWindowModality(Qt.ApplicationModal)
@@ -2225,7 +2197,7 @@ def set_icon_and_properties_ui(self, item_name):
"""Adds properties UI to given project item.
Args:
- item_name (str): item's name
+ item_name (str): Item's name
"""
project_item = self._project.get_item(item_name)
icon = self.project_item_icon(project_item.item_type())
@@ -2237,10 +2209,10 @@ def project_item_properties_ui(self, item_type):
"""Returns the properties tab widget's ui.
Args:
- item_type (str): project item's type
+ item_type (str): Project item's type
Returns:
- QWidget: item's properties tab widget
+ QWidget: Item's properties tab widget
"""
return self._item_properties_uis[item_type].ui
@@ -2257,13 +2229,8 @@ def _open_project_directory(self, _):
@Slot(bool)
def _open_project_item_directory(self, _):
- """Opens project item's directory in system's file browser."""
- selection_model = self.ui.treeView_project.selectionModel()
- current = selection_model.currentIndex()
- if not current.isValid():
- return
- item = self.project_item_model.item(current)
- item.project_item.open_directory()
+ """Opens active project item's directory in system's file browser."""
+ self.active_project_item.open_directory()
@Slot(bool)
def _remove_selected_items(self, _):
@@ -2309,12 +2276,8 @@ def _remove_selected_items(self, _):
@Slot(bool)
def _rename_project_item(self, _):
- """Renames current project item."""
- selection_model = self.ui.treeView_project.selectionModel()
- current = selection_model.currentIndex()
- if not current.isValid():
- return
- item = self.project_item_model.item(current)
+ """Renames active project item."""
+ item = self.active_project_item
answer = QInputDialog.getText(
self, "Rename Item", "New name:", text=item.name, flags=Qt.WindowTitleHint | Qt.WindowCloseButtonHint
)
@@ -2323,25 +2286,14 @@ def _rename_project_item(self, _):
new_name = answer[0]
self.undo_stack.push(RenameProjectItemCommand(self._project, item.name, new_name))
- def item_category_context_menu(self):
- """Creates a context menu for category items.
-
- Returns:
- QMenu: category context menu
- """
- menu = QMenu(self)
- menu.setToolTipsVisible(True)
- menu.addAction(self.ui.actionOpen_project_directory)
- return menu
-
def project_item_context_menu(self, additional_actions):
"""Creates a context menu for project items.
Args:
- additional_actions (list of QAction): actions to be prepended to the menu
+ additional_actions (list of QAction): Actions to be prepended to the menu
Returns:
- QMenu: project item context menu
+ QMenu: Project item context menu
"""
menu = QMenu(self)
menu.setToolTipsVisible(True)
@@ -2543,13 +2495,13 @@ def restore_and_activate(self):
def _make_log_entry_title(title):
return f'{title}'
- def start_execution(self, timestamp):
- """Starts execution.
+ def make_execution_timestamp(self, timestamp):
+ """Appends a timestamp to Event Log.
Args:
- timestamp (str): time stamp
+ timestamp (str): Time stamp
"""
- self.ui.textBrowser_eventlog.start_execution(timestamp)
+ self.ui.textBrowser_eventlog.make_log_entry_point(timestamp)
def add_log_message(self, item_name, filter_id, message):
"""Adds a message to an item's execution log.
@@ -2560,22 +2512,3 @@ def add_log_message(self, item_name, filter_id, message):
message (str): formatted message
"""
self.ui.textBrowser_eventlog.add_log_message(item_name, filter_id, message)
-
- def _notify_about_upgrades_at_startup(self):
- """Shows upgrade information at application start-up."""
- upgrade_notification_anchor = (
- "Click here"
- )
- upgrade_msg = f"{upgrade_notification_anchor} to learn more about the upcoming 0.8 upgrade"
- self.msg_warning.emit(upgrade_msg)
- key = "appSettings/showUpgradeNotification"
- show_notification_dialog = self._qsettings.value(key, defaultValue=True, type=bool)
- if show_notification_dialog:
- self._show_upgrade_notification_dialog()
- self._qsettings.setValue(key, False)
-
- def _show_upgrade_notification_dialog(self):
- """Shows the Upgrade notification dialog."""
- dialog = UpgradeNotificationDialog(self)
- dialog.open()
diff --git a/spinetoolbox/version.py b/spinetoolbox/version.py
index e45beae8a..172b229d1 100644
--- a/spinetoolbox/version.py
+++ b/spinetoolbox/version.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,12 +10,9 @@
# this program. If not, see .
######################################################################################################################
-"""Version info for Spine Toolbox package. Inspired by python sys.version and sys.version_info.
-"""
-
+"""Version info for Spine Toolbox package. Inspired by python sys.version and sys.version_info."""
import re
from typing import NamedTuple
-
from ._version import version_tuple
@@ -32,9 +30,9 @@ class VersionInfo(NamedTuple):
def __str__(self) -> str:
"""Create a version string following PEP 440"""
version = f"{self.major}.{self.minor}.{self.micro}"
- if self.releaselevel == 'final': # pylint: disable=no-else-return
+ if self.releaselevel == "final": # pylint: disable=no-else-return
return version
- elif self.releaselevel.startswith('dev'):
+ elif self.releaselevel.startswith("dev"):
return version + f".dev{self.serial}"
else:
return version + f"-{self.releaselevel}.{self.serial}"
diff --git a/spinetoolbox/widgets/__init__.py b/spinetoolbox/widgets/__init__.py
index 0b60c4d9c..c205398e5 100644
--- a/spinetoolbox/widgets/__init__.py
+++ b/spinetoolbox/widgets/__init__.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,6 +10,4 @@
# this program. If not, see .
######################################################################################################################
-"""
-Init file for widgets package. Intentionally empty.
-"""
+"""Init file for widgets package. Intentionally empty."""
diff --git a/spinetoolbox/widgets/about_widget.py b/spinetoolbox/widgets/about_widget.py
index 04fcd65d9..ff7756ba5 100644
--- a/spinetoolbox/widgets/about_widget.py
+++ b/spinetoolbox/widgets/about_widget.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-A widget for presenting basic information about the application.
-"""
-
+"""A widget for presenting basic information about the application."""
import os
import sys
import platform
diff --git a/spinetoolbox/widgets/add_project_item_widget.py b/spinetoolbox/widgets/add_project_item_widget.py
index a5cfb4596..df9f5b354 100644
--- a/spinetoolbox/widgets/add_project_item_widget.py
+++ b/spinetoolbox/widgets/add_project_item_widget.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Widget shown to user when a new Project Item is created.
-"""
-
+"""Widget shown to user when a new Project Item is created."""
from PySide6.QtWidgets import QWidget, QStatusBar
from PySide6.QtCore import Slot, Qt
from spine_engine.utils.helpers import shorten
@@ -51,20 +49,20 @@ def __init__(self, toolbox, x, y, class_, spec=""):
if toolbox.supports_specifications(class_.item_type()):
self.ui.comboBox_specification.setModel(toolbox.filtered_spec_factory_models[class_.item_type()])
if spec:
- self.ui.comboBox_specification.setCurrentText(spec)
+ self.ui.comboBox_specification.hide()
prefix = spec
else:
prefix = class_.item_type()
self.ui.comboBox_specification.setCurrentIndex(-1)
else:
prefix = class_.item_type()
- self.ui.comboBox_specification.setEnabled(False)
+ self.ui.comboBox_specification.hide()
existing_item_names = toolbox.project().all_item_names
self.name = unique_name(prefix, existing_item_names) if prefix in existing_item_names else prefix
- self.ui.lineEdit_name.setText(self.name)
- self.ui.lineEdit_name.selectAll()
self.description = ""
self.connect_signals()
+ self.ui.lineEdit_name.setText(self.name)
+ self.ui.lineEdit_name.selectAll()
self.ui.lineEdit_name.setFocus()
# Ensure this window gets garbage-collected when closed
self.setAttribute(Qt.WA_DeleteOnClose)
@@ -109,6 +107,10 @@ def handle_ok_clicked(self):
self.statusbar.showMessage(msg, 3000)
return
self.call_add_item()
+ self._toolbox.ui.graphicsView.scene().clearSelection()
+ for icon in self._toolbox.ui.graphicsView.scene().project_item_icons():
+ if icon.name() == self.name:
+ icon.setSelected(True)
self.close()
def call_add_item(self):
diff --git a/spinetoolbox/widgets/add_up_spine_opt_wizard.py b/spinetoolbox/widgets/add_up_spine_opt_wizard.py
index 0ed3f6cda..328ac963e 100644
--- a/spinetoolbox/widgets/add_up_spine_opt_wizard.py
+++ b/spinetoolbox/widgets/add_up_spine_opt_wizard.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Classes for custom QDialogs for julia setup.
-"""
-
+"""Classes for custom QDialogs for julia setup."""
from enum import IntEnum, auto
from PySide6.QtWidgets import (
QWidget,
@@ -169,11 +167,11 @@ def initializePage(self):
args = [
f"--project={julia_project}",
"-e",
- 'import Pkg; '
+ "import Pkg; "
'manifest = joinpath(dirname(Base.active_project()), "Manifest.toml"); '
- 'pkgs = isfile(manifest) ? Pkg.TOML.parsefile(manifest) : Dict(); '
+ "pkgs = isfile(manifest) ? Pkg.TOML.parsefile(manifest) : Dict(); "
'manifest_format = get(pkgs, "manifest_format", missing); '
- 'if manifest_format === missing '
+ "if manifest_format === missing "
'spine_opt = get(pkgs, "SpineOpt", nothing) '
'else spine_opt = get(pkgs["deps"], "SpineOpt", nothing) end; '
'if spine_opt != nothing println(spine_opt[1]["version"]) end; ',
diff --git a/spinetoolbox/widgets/array_editor.py b/spinetoolbox/widgets/array_editor.py
index 990893548..9a68b0b52 100644
--- a/spinetoolbox/widgets/array_editor.py
+++ b/spinetoolbox/widgets/array_editor.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,13 +10,9 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains an editor widget for array type parameter values.
-"""
-
+"""Contains an editor widget for array type parameter values."""
from PySide6.QtCore import QModelIndex, QPoint, Qt, Slot
-from PySide6.QtWidgets import QWidget
-
+from PySide6.QtWidgets import QHeaderView, QWidget
from spinedb_api import DateTime, Duration, ParameterValueFormatError
from .array_value_editor import ArrayValueEditor
from .indexed_value_table_context_menu import ArrayTableContextMenu
@@ -49,7 +46,9 @@ def __init__(self, parent=None):
self._ui.array_table_view.setModel(self._model)
self._ui.array_table_view.setContextMenuPolicy(Qt.CustomContextMenu)
self._ui.array_table_view.customContextMenuRequested.connect(self._show_table_context_menu)
- self._ui.array_table_view.horizontalHeader().sectionDoubleClicked.connect(self._open_header_editor)
+ header = self._ui.array_table_view.horizontalHeader()
+ header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
+ header.sectionDoubleClicked.connect(self._open_header_editor)
self._ui.value_type_combo_box.currentTextChanged.connect(self._change_value_type)
delegate = ParameterValueElementDelegate(self._ui.array_table_view)
delegate.value_editor_requested.connect(self.open_value_editor)
diff --git a/spinetoolbox/widgets/array_value_editor.py b/spinetoolbox/widgets/array_value_editor.py
index 6ce5258bb..2fc5c172f 100644
--- a/spinetoolbox/widgets/array_value_editor.py
+++ b/spinetoolbox/widgets/array_value_editor.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,9 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-An editor dialog for Array elements.
-"""
+"""An editor dialog for Array elements."""
from PySide6.QtCore import Qt
from .duration_editor import DurationEditor
from .datetime_editor import DatetimeEditor
diff --git a/spinetoolbox/widgets/code_text_edit.py b/spinetoolbox/widgets/code_text_edit.py
index 4142cf59d..82e87b916 100644
--- a/spinetoolbox/widgets/code_text_edit.py
+++ b/spinetoolbox/widgets/code_text_edit.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Provides simple text editor for programming purposes.
-"""
-
+"""Provides simple text editor for programming purposes."""
from pygments.styles import get_style_by_name
from pygments.lexers import get_lexer_by_name
from pygments.util import ClassNotFound
@@ -35,9 +33,9 @@ def __init__(self, *arg, **kwargs):
self._right_margin = 16
font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
self.setFont(font)
- foreground_color = self._style.styles[Token]
+ self.foreground_color = self._style.styles[Token]
self.setStyleSheet(
- f"QPlainTextEdit {{background-color: {self._style.background_color}; color: {foreground_color};}}"
+ f"QPlainTextEdit {{background-color: {self._style.background_color}; color: {self.foreground_color};}}"
)
self.blockCountChanged.connect(self._update_line_number_area_width)
self.updateRequest.connect(self._update_line_number_area)
@@ -109,6 +107,14 @@ def _update_line_number_area_cursor_position(self):
self._line_number_area.update(0, top, self._line_number_area.width(), bottom - top)
self._cursor_block = new_cursor_block
+ def set_enabled_with_greyed(self, enabled):
+ super().setEnabled(enabled)
+ if enabled:
+ x = f"QPlainTextEdit {{background-color: {self._style.background_color}; color: {self.foreground_color};}}"
+ else:
+ x = f"QPlainTextEdit {{background-color: #737373; color: {self.foreground_color};}}"
+ self.setStyleSheet(x)
+
def resizeEvent(self, event):
super().resizeEvent(event)
rect = self.contentsRect()
diff --git a/spinetoolbox/widgets/commit_dialog.py b/spinetoolbox/widgets/commit_dialog.py
index 8b070771c..7b2e160e4 100644
--- a/spinetoolbox/widgets/commit_dialog.py
+++ b/spinetoolbox/widgets/commit_dialog.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Classes for custom QDialogs to add edit and remove database items.
-"""
-
+"""Classes for custom QDialogs to add edit and remove database items."""
from PySide6.QtWidgets import QDialog, QVBoxLayout, QPlainTextEdit, QDialogButtonBox, QApplication
from PySide6.QtCore import Slot, Qt
from PySide6.QtGui import QAction
@@ -30,7 +28,7 @@ def __init__(self, parent, *db_names):
super().__init__(parent)
self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False)
self.setWindowModality(Qt.ApplicationModal)
- self.setWindowTitle('Commit changes to {}'.format(",".join(db_names)))
+ self.setWindowTitle("Commit changes to {}".format(",".join(db_names)))
form = QVBoxLayout(self)
form.setContentsMargins(4, 4, 4, 4)
self.action_accept = QAction(self)
@@ -44,7 +42,7 @@ def __init__(self, parent, *db_names):
self.commit_msg_edit.addAction(self.action_accept)
button_box = QDialogButtonBox()
button_box.addButton(QDialogButtonBox.StandardButton.Cancel)
- self.commit_button = button_box.addButton('Commit', QDialogButtonBox.ButtonRole.AcceptRole)
+ self.commit_button = button_box.addButton("Commit", QDialogButtonBox.ButtonRole.AcceptRole)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
form.addWidget(self.commit_msg_edit)
diff --git a/spinetoolbox/widgets/custom_combobox.py b/spinetoolbox/widgets/custom_combobox.py
index cd3211307..377b3a0cf 100644
--- a/spinetoolbox/widgets/custom_combobox.py
+++ b/spinetoolbox/widgets/custom_combobox.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains a custom combo box for the custom open project dialog.
-"""
-
+"""Contains custom combo box classes."""
import os
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QComboBox, QStyle, QStylePainter, QStyleOptionComboBox, QDialog, QAbstractItemView
@@ -20,6 +18,18 @@
from .notification import Notification
+class CustomQComboBox(QComboBox):
+ """A custom QComboBox for showing kernels in Settings->Tools."""
+
+ def mouseMoveEvent(self, e):
+ """Catch mouseMoveEvent and accept it because the comboBox
+ popup (QListView) has mouse tracking on as default.
+ This makes sure the comboBox popup appears in correct
+ position and clicking on the combobox repeatedly does
+ not move the Settings window."""
+ e.accept()
+
+
class ElidedCombobox(QComboBox):
"""Combobox with elided text."""
@@ -28,7 +38,6 @@ def paintEvent(self, event):
self.initStyleOption(opt)
p = QStylePainter(self)
p.drawComplexControl(QStyle.ComplexControl.CC_ComboBox, opt)
-
text_rect = self.style().subControlRect(
QStyle.ComplexControl.CC_ComboBox, opt, QStyle.SubControl.SC_ComboBoxEditField, self
)
diff --git a/spinetoolbox/widgets/custom_delegates.py b/spinetoolbox/widgets/custom_delegates.py
index 11524df98..8f6463add 100644
--- a/spinetoolbox/widgets/custom_delegates.py
+++ b/spinetoolbox/widgets/custom_delegates.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,11 +10,8 @@
# this program. If not, see .
######################################################################################################################
-"""
-Custom item delegates.
-"""
-
-from PySide6.QtCore import Qt, Signal, QEvent, QPoint, QRect
+"""Custom item delegates."""
+from PySide6.QtCore import Qt, Signal, QEvent, QPoint, QRect, QModelIndex
from PySide6.QtWidgets import (
QComboBox,
QStyledItemDelegate,
@@ -65,7 +63,7 @@ def _finalize_editing(self, editor):
class CheckBoxDelegate(QStyledItemDelegate):
"""A delegate that places a fully functioning QCheckBox."""
- data_committed = Signal("QModelIndex", "QVariant")
+ data_committed = Signal(QModelIndex, object)
def __init__(self, parent, centered=True):
"""
diff --git a/spinetoolbox/widgets/custom_menus.py b/spinetoolbox/widgets/custom_menus.py
index 732af3003..133b469c8 100644
--- a/spinetoolbox/widgets/custom_menus.py
+++ b/spinetoolbox/widgets/custom_menus.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Classes for custom context menus and pop-up menus.
-"""
-
+"""Classes for custom context menus and pop-up menus."""
import os
from PySide6.QtWidgets import QMenu, QWidgetAction
from PySide6.QtGui import QIcon, QAction
@@ -238,7 +236,7 @@ def __init__(self, parent):
"""
super().__init__(parent)
self._filter = None
- self._remove_filter = QAction('Remove filters', None)
+ self._remove_filter = QAction("Remove filters", None)
self._filter_action = QWidgetAction(self)
self.addAction(self._remove_filter)
@@ -278,7 +276,3 @@ def _change_filter(self):
def emit_filter_changed(self, valid_values):
raise NotImplementedError()
-
- def wipe_out(self):
- self._filter._filter_model.set_list(set())
- self.deleteLater()
diff --git a/spinetoolbox/widgets/custom_qgraphicsscene.py b/spinetoolbox/widgets/custom_qgraphicsscene.py
index 97e7c6f5f..73966a3a1 100644
--- a/spinetoolbox/widgets/custom_qgraphicsscene.py
+++ b/spinetoolbox/widgets/custom_qgraphicsscene.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,12 +10,9 @@
# this program. If not, see .
######################################################################################################################
-"""
-Custom QGraphicsScene used in the Design View.
-"""
-
+"""Custom QGraphicsScene used in the Design View."""
import math
-from PySide6.QtCore import Qt, Signal, Slot, QItemSelectionModel, QPointF, QEvent, QTimer
+from PySide6.QtCore import Qt, Signal, Slot, QPointF, QEvent
from PySide6.QtWidgets import QGraphicsItem, QGraphicsScene
from PySide6.QtGui import QColor, QPen, QBrush
from ..project_item_icon import ProjectItemIcon
@@ -46,6 +44,9 @@ class DesignGraphicsScene(CustomGraphicsScene):
Mainly, it handles drag and drop events of ProjectItemDragMixin sources.
"""
+ link_about_to_be_drawn = Signal()
+ link_drawing_finished = Signal()
+
def __init__(self, parent, toolbox):
"""
Args:
@@ -67,20 +68,9 @@ def __init__(self, parent, toolbox):
self._jump_drawer.hide()
self.link_drawer = None
self.icon_group = set() # Group of project item icons that are moving together
- self.dirty_links = set()
- self._timer = QTimer(self)
- self._timer.setInterval(5)
- self._timer.timeout.connect(self._handle_timeout)
- self._timer.start()
self._cat = Cat(self)
self.connect_signals()
- @Slot()
- def _handle_timeout(self):
- for link in self.dirty_links:
- link.update_geometry()
- self.dirty_links.clear()
-
def clear_icons_and_links(self):
for item in self.items():
if isinstance(item, (Link, JumpLink, ProjectItemIcon)):
@@ -95,26 +85,36 @@ def mouseMoveEvent(self, event):
super().mouseMoveEvent(event)
def mousePressEvent(self, event):
- """Puts link drawer to sleep and log message if it looks like the user doesn't know what they're doing."""
- if (
- self._toolbox.qsettings().value("appSettings/dragToDrawLinks", defaultValue="false") == "false"
- and self._finish_link()
- ):
- return
+ """Puts link drawer to sleep and logs message if it looks like the user doesn't know what they're doing."""
+ if self.link_drawer is not None:
+ if event.button() == Qt.MouseButton.RightButton:
+ return
+ if (
+ self._toolbox.qsettings().value("appSettings/dragToDrawLinks", defaultValue="false") == "false"
+ and event.button() == Qt.MouseButton.LeftButton
+ and self._finish_link()
+ ):
+ event.accept()
+ return
super().mousePressEvent(event)
def mouseReleaseEvent(self, event):
- """Makes link if drawer is released over a valid connector button."""
- if (
- self._toolbox.qsettings().value("appSettings/dragToDrawLinks", defaultValue="false") == "true"
- and self._finish_link()
- ):
- return
+ """Makes link if drawer is released over a valid connector button or cancel link drawing on right button."""
+ if self.link_drawer is not None:
+ if event.button() == Qt.MouseButton.RightButton:
+ self.link_drawer.sleep()
+ event.accept()
+ return
+ if (
+ self._toolbox.qsettings().value("appSettings/dragToDrawLinks", defaultValue="false") == "true"
+ and event.button() == Qt.MouseButton.LeftButton
+ and self._finish_link()
+ ):
+ event.accept()
+ return
super().mouseReleaseEvent(event)
def _finish_link(self):
- if self.link_drawer is None:
- return False
if self.link_drawer.src_connector.isUnderMouse():
self.link_drawer.sleep()
return False
@@ -122,6 +122,9 @@ def _finish_link(self):
self.link_drawer.sleep()
self.emit_connection_failed()
return False
+ if self.link_drawer.src_connector == self.link_drawer.dst_connector:
+ self.link_drawer.sleep()
+ return False
self.link_drawer.dst_connector.set_normal_brush()
self.link_drawer.add_link()
return True
@@ -146,7 +149,7 @@ def project_item_icons(self):
@Slot()
def handle_selection_changed(self):
- """Synchronizes selection with the project tree."""
+ """Activates items or links based on currently selected items (or links)."""
selected_items = set(self.selectedItems())
if self._last_selected_items == selected_items:
return
@@ -160,23 +163,10 @@ def handle_selection_changed(self):
links.append(item)
# Set active project item and active link in toolbox
active_project_item = (
- self._toolbox.project_item_model.get_item(project_item_icons[0].name()).project_item
- if len(project_item_icons) == 1
- else None
+ self._toolbox.project().get_item(project_item_icons[0].name()) if len(project_item_icons) == 1 else None
)
active_link_item = links[0].item if len(links) == 1 else None
- # Sync selection with project tree view
selected_item_names = {icon.name() for icon in project_item_icons}
- self._toolbox.sync_item_selection_with_scene = False
- for ind in self._toolbox.project_item_model.leaf_indexes():
- item_name = self._toolbox.project_item_model.item(ind).name
- cmd = QItemSelectionModel.Select if item_name in selected_item_names else QItemSelectionModel.Deselect
- self._toolbox.ui.treeView_project.selectionModel().select(ind, cmd)
- self._toolbox.sync_item_selection_with_scene = True
- # Make last item selected the current index in project tree view
- if project_item_icons:
- last_ind = self._toolbox.project_item_model.find_item(project_item_icons[-1].name())
- self._toolbox.ui.treeView_project.selectionModel().setCurrentIndex(last_ind, QItemSelectionModel.NoUpdate)
selected_link_icons = [conn.parent for link in links for conn in (link.src_connector, link.dst_connector)]
selected_item_names |= set(icon.name() for icon in selected_link_icons)
self._toolbox.refresh_active_elements(active_project_item, active_link_item, selected_item_names)
@@ -199,7 +189,7 @@ def set_bg_choice(self, bg_choice):
self.bg_choice = bg_choice
def dragLeaveEvent(self, event):
- """Accept event."""
+ """Accepts event."""
event.accept()
def dragEnterEvent(self, event):
diff --git a/spinetoolbox/widgets/custom_qgraphicsviews.py b/spinetoolbox/widgets/custom_qgraphicsviews.py
index 7bd1b68a8..7a85d30e7 100644
--- a/spinetoolbox/widgets/custom_qgraphicsviews.py
+++ b/spinetoolbox/widgets/custom_qgraphicsviews.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,15 +10,11 @@
# this program. If not, see .
######################################################################################################################
-"""
-Classes for custom QGraphicsViews for the Design and Graph views.
-"""
-
+"""Classes for custom QGraphicsViews for the Design and Graph views."""
import math
from PySide6.QtWidgets import QGraphicsView, QGraphicsItem, QGraphicsRectItem
-from PySide6.QtGui import QCursor
-from PySide6.QtCore import Slot, Qt, QTimeLine, QRectF
-from spine_engine.project_item.connection import Connection
+from PySide6.QtGui import QContextMenuEvent, QCursor, QMouseEvent
+from PySide6.QtCore import QTimer, Slot, Qt, QTimeLine, QRectF
from ..project_item_icon import ProjectItemIcon
from ..project_commands import AddConnectionCommand, AddJumpCommand, RemoveConnectionsCommand, RemoveJumpsCommand
from ..link import Link, JumpLink
@@ -26,14 +23,15 @@
class CustomQGraphicsView(QGraphicsView):
- """Super class for Design and Entity QGraphicsViews.
+ """Super class for Design and Entity QGraphicsViews."""
- Attributes:
- parent (QWidget): Parent widget
- """
+ DRAG_MIN_DURATION = 150
def __init__(self, parent):
- """Init CustomQGraphicsView."""
+ """
+ Args:
+ parent (QWidget): parent widget
+ """
super().__init__(parent=parent)
self._zoom_factor_base = 1.0015
self._angle = 120
@@ -42,6 +40,9 @@ def __init__(self, parent):
self._items_fitting_zoom = 1.0
self._max_zoom = 10.0
self._min_zoom = 0.1
+ self._previous_mouse_pos = None
+ self._last_right_mouse_press = None
+ self._enabled_context_menu_policy = self.contextMenuPolicy()
@property
def _qsettings(self):
@@ -59,11 +60,10 @@ def reset_zoom(self):
self._set_preferred_scene_rect()
def keyPressEvent(self, event):
- """Overridden method. Enable zooming with plus and minus keys (comma resets zoom).
- Send event downstream to QGraphicsItems if pressed key is not handled here.
+ """Enables zooming with plus and minus keys (comma resets zoom).
Args:
- event (QKeyEvent): Pressed key
+ event (QKeyEvent): key press event
"""
if event.key() == Qt.Key_Plus:
self.zoom_in()
@@ -75,32 +75,93 @@ def keyPressEvent(self, event):
super().keyPressEvent(event)
def mousePressEvent(self, event):
- """Set rubber band selection mode if Control pressed.
- Enable resetting the zoom factor from the middle mouse button.
+ """Sets rubber band selection mode if Control or right mouse button is pressed.
+ Enables resetting the zoom factor from the middle mouse button.
"""
+ self._previous_mouse_pos = event.position().toPoint()
item = self.itemAt(event.position().toPoint())
if not item or not item.acceptedMouseButtons() & event.buttons():
- if event.modifiers() & Qt.ControlModifier:
- self.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
+ button = event.button()
+ if button == Qt.MouseButton.LeftButton:
self.viewport().setCursor(Qt.CrossCursor)
- if event.button() == Qt.MiddleButton:
+ elif button == Qt.MouseButton.MiddleButton:
self.reset_zoom()
+ if button == Qt.MouseButton.RightButton:
+ self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
+ self._last_right_mouse_press = event.timestamp()
+ event = _fake_left_button_event(event)
super().mousePressEvent(event)
+ def mouseMoveEvent(self, event):
+ if (
+ event.buttons() & Qt.MouseButton.RightButton == Qt.MouseButton.RightButton
+ and self.dragMode() == QGraphicsView.DragMode.ScrollHandDrag
+ and self._drag_duration_passed(event)
+ ):
+ if self._previous_mouse_pos is not None:
+ delta = event.position().toPoint() - self._previous_mouse_pos
+ self._scroll_scene_by(delta.x(), delta.y())
+ self._previous_mouse_pos = event.position().toPoint()
+ event = _fake_left_button_event(event)
+ super().mouseMoveEvent(event)
+
def mouseReleaseEvent(self, event):
"""Reestablish scroll hand drag mode."""
+ context_menu_disabled = False
+ if self.dragMode() == QGraphicsView.DragMode.ScrollHandDrag:
+ if self._drag_duration_passed(event):
+ context_menu_disabled = True
+ self.disable_context_menu()
+ else:
+ self.contextMenuEvent(
+ QContextMenuEvent(QContextMenuEvent.Reason.Mouse, event.pos(), event.globalPos(), event.modifiers())
+ )
+ event = _fake_left_button_event(event)
super().mouseReleaseEvent(event)
+ self.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
+ self._previous_mouse_pos = None
+ self._last_right_mouse_press = None
+ if context_menu_disabled:
+ self.enable_context_menu()
item = next(iter([x for x in self.items(event.position().toPoint()) if x.hasCursor()]), None)
- was_not_rubber_band_drag = self.dragMode() != QGraphicsView.DragMode.RubberBandDrag
- self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
- if item and was_not_rubber_band_drag:
+ if item:
self.viewport().setCursor(item.cursor())
else:
self.viewport().setCursor(Qt.ArrowCursor)
+ def _scroll_scene_by(self, dx, dy):
+ if dx == dy == 0:
+ return
+ scene_rect = self.sceneRect()
+ view_scene_rect = self.mapFromScene(scene_rect).boundingRect()
+ view_rect = self.viewport().rect()
+ scene_dx = abs((self.mapToScene(0, 0) - self.mapToScene(dx, 0)).x())
+ scene_dy = abs((self.mapToScene(0, 0) - self.mapToScene(0, dy)).y())
+ if dx < 0 and view_rect.right() - dx >= view_scene_rect.right():
+ scene_rect.adjust(0, 0, scene_dx, 0)
+ elif dx > 0 and view_rect.left() - dx <= view_scene_rect.left():
+ scene_rect.adjust(-scene_dx, 0, 0, 0)
+ if dy < 0 and view_rect.bottom() - dy >= view_scene_rect.bottom():
+ scene_rect.adjust(0, 0, 0, scene_dy)
+ elif dy > 0 and view_rect.top() - dy <= view_scene_rect.top():
+ scene_rect.adjust(0, -scene_dy, 0, 0)
+ self.scene().setSceneRect(scene_rect)
+
def _use_smooth_zoom(self):
return self._qsettings.value("appSettings/smoothZoom", defaultValue="false") == "true"
+ def _drag_duration_passed(self, mouse_event):
+ """Test is drag duration has passed.
+
+ Args:
+ mouse_event (QMouseEvent): current mouse event
+ """
+ return (
+ mouse_event.timestamp() - self._last_right_mouse_press > self.DRAG_MIN_DURATION
+ if self._last_right_mouse_press is not None
+ else False
+ )
+
def wheelEvent(self, event):
"""Zooms in/out.
@@ -157,7 +218,7 @@ def setScene(self, scene):
Sets a new scene to this view.
Args:
- scene (ShrinkingScene): a new scene
+ scene (DesignGraphicsScene): a new scene
"""
super().setScene(scene)
scene.item_move_finished.connect(self._handle_item_move_finished)
@@ -276,6 +337,19 @@ def _set_preferred_scene_rect(self):
items_scene_rect = self.scene().itemsBoundingRect()
self.scene().setSceneRect(viewport_scene_rect.united(items_scene_rect))
+ @Slot()
+ def disable_context_menu(self):
+ """Disables the context menu."""
+ self._enabled_context_menu_policy = self.contextMenuPolicy()
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.PreventContextMenu)
+
+ @Slot()
+ def enable_context_menu(self):
+ """Enables the context menu."""
+ # We use timer here to delay setting the policy.
+ # Otherwise, using right-click to cancel link drawing would still open the context menu.
+ QTimer.singleShot(0, lambda: self.setContextMenuPolicy(self._enabled_context_menu_policy))
+
class DesignQGraphicsView(CustomQGraphicsView):
"""QGraphicsView for the Design View."""
@@ -285,7 +359,7 @@ def __init__(self, parent):
Args:
parent (QWidget): parent widget
"""
- super().__init__(parent=parent) # Parent is passed to QWidget's constructor
+ super().__init__(parent=parent)
self._toolbox = None
@property
@@ -295,7 +369,10 @@ def _qsettings(self):
def set_ui(self, toolbox):
"""Set a new scene into the Design View when app is started."""
self._toolbox = toolbox
- self.setScene(DesignGraphicsScene(self, toolbox))
+ scene = DesignGraphicsScene(self, toolbox)
+ scene.link_about_to_be_drawn.connect(self.disable_context_menu)
+ scene.link_drawing_finished.connect(self.enable_context_menu)
+ self.setScene(scene)
def reset_zoom(self):
super().reset_zoom()
@@ -502,3 +579,21 @@ def contextMenuEvent(self, event):
event.accept()
global_pos = self.viewport().mapToGlobal(event.pos())
self._toolbox.show_project_or_item_context_menu(global_pos, None)
+
+
+def _fake_left_button_event(mouse_event):
+ """Makes a left-click mouse event that is otherwise close of given event.
+
+ Args:
+ mouse_event (QMouseEvent): mouse event
+
+ Returns:
+ QMouseEvent: left-click mouse event
+ """
+ return QMouseEvent(
+ mouse_event.type(),
+ mouse_event.pos(),
+ Qt.MouseButton.LeftButton,
+ Qt.MouseButton.LeftButton,
+ Qt.KeyboardModifier.NoModifier,
+ )
diff --git a/spinetoolbox/widgets/custom_qlineedits.py b/spinetoolbox/widgets/custom_qlineedits.py
index 5ffb729af..fdc6979d1 100644
--- a/spinetoolbox/widgets/custom_qlineedits.py
+++ b/spinetoolbox/widgets/custom_qlineedits.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Classes for custom line edits.
-"""
-
+"""Contains a custom line edit."""
from PySide6.QtWidgets import QLineEdit
from .custom_qwidgets import UndoRedoMixin
diff --git a/spinetoolbox/widgets/custom_qtableview.py b/spinetoolbox/widgets/custom_qtableview.py
index f646574d3..a26a67fa2 100644
--- a/spinetoolbox/widgets/custom_qtableview.py
+++ b/spinetoolbox/widgets/custom_qtableview.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Custom QTableView classes that support copy-paste and the like.
-"""
-
+"""Custom QTableView classes that support copy-paste and the like."""
import csv
import ctypes
import io
@@ -21,7 +19,6 @@
from numbers import Number
import re
from operator import methodcaller
-
from PySide6.QtWidgets import QTableView, QApplication
from PySide6.QtCore import Qt, Slot, QItemSelection, QItemSelectionModel, QPoint
from PySide6.QtGui import QKeySequence, QIcon, QAction
@@ -51,6 +48,7 @@ def __init__(self, parent=None):
self._delete_action = QAction("Delete", self)
self._delete_action.setShortcut(QKeySequence.Delete)
self.addAction(self._delete_action)
+ self._pasted_data_converters = {}
self._delete_action.triggered.connect(self.delete_content)
def init_copy_and_paste_actions(self):
@@ -124,11 +122,17 @@ def copy(self, _=False):
continue
data = self.model().index(i, j).data(Qt.ItemDataRole.EditRole)
if data is not None:
- try:
- number = float(data)
- str_data = locale.str(number)
- except ValueError:
- str_data = str(data)
+ if isinstance(data, bool):
+ str_data = "true" if data else "false"
+ else:
+ if isinstance(data, int):
+ str_data = str(data)
+ else:
+ try:
+ number = float(data)
+ str_data = locale.str(number)
+ except ValueError:
+ str_data = str(data)
else:
str_data = ""
row.append(str_data)
@@ -204,6 +208,7 @@ def paste_on_selection(self):
rows = [x for r in selection for x in range(r.top(), r.bottom() + 1) if not is_row_hidden(x)]
is_column_hidden = self.horizontalHeader().isSectionHidden
columns = [x for r in selection for x in range(r.left(), r.right() + 1) if not is_column_hidden(x)]
+ converters = self._converters() if self._pasted_data_converters else {}
model_index = self.model().index
for row in rows:
for column in columns:
@@ -213,6 +218,11 @@ def paste_on_selection(self):
j = (column - columns[0]) % len(data[i])
value = data[i][j]
indexes.append(index)
+ if converters:
+ convert = converters.get(column)
+ if convert is not None:
+ values.append(convert(value))
+ continue
values.append(value)
self.model().batch_set_data(indexes, values)
return True
@@ -252,15 +262,17 @@ def paste_normal(self):
visual_column += 1
# Insert extra rows if needed:
last_row = max(rows)
- row_count = self.model().rowCount()
+ model = self.model()
+ row_count = model.rowCount()
if last_row >= row_count:
- self.model().insertRows(row_count, last_row - row_count + 1)
+ model.insertRows(row_count, last_row - row_count + 1)
# Insert extra columns if needed:
last_column = max(columns)
- column_count = self.model().columnCount()
+ column_count = model.columnCount()
if last_column >= column_count:
- self.model().insertColumns(column_count, last_column - column_count + 1)
- model_index = self.model().index
+ model.insertColumns(column_count, last_column - column_count + 1)
+ converters = self._converters() if self._pasted_data_converters else {}
+ model_index = model.index
for i, row in enumerate(rows):
try:
line = data[i]
@@ -274,10 +286,28 @@ def paste_normal(self):
index = model_index(row, column)
if index.flags() & Qt.ItemIsEditable:
indexes.append(index)
+ if converters:
+ convert = converters.get(column)
+ if convert is not None:
+ values.append(convert(value))
+ continue
values.append(value)
- self.model().batch_set_data(indexes, values)
+ model.batch_set_data(indexes, values)
return True
+ def set_column_converter_for_pasting(self, header, converter):
+ self._pasted_data_converters[header] = converter
+
+ def _converters(self):
+ converters = {}
+ model = self.model()
+ for column in range(model.columnCount()):
+ label = model.headerData(column, Qt.Orientation.Horizontal)
+ converter = self._pasted_data_converters.get(label)
+ if converter is not None:
+ converters[column] = converter
+ return converters
+
class AutoFilterCopyPasteTableView(CopyPasteTableView):
"""Custom QTableView class with autofilter functionality."""
@@ -289,7 +319,7 @@ def __init__(self, parent):
"""
super().__init__(parent=parent)
self._show_filter_menu_action = QAction(self)
- self._show_filter_menu_action.setShortcut(Qt.ALT | Qt.Key_Down)
+ self._show_filter_menu_action.setShortcut(QKeySequence(Qt.Modifier.ALT.value | Qt.Key.Key_Down.value))
self._show_filter_menu_action.setShortcutContext(Qt.WidgetShortcut)
self._show_filter_menu_action.triggered.connect(self._trigger_filter_menu)
self.addAction(self._show_filter_menu_action)
@@ -358,7 +388,7 @@ def copy(self, _=False):
else:
data_values[row - row_first] = data
with io.StringIO() as output:
- writer = csv.writer(output, delimiter='\t')
+ writer = csv.writer(output, delimiter="\t")
with system_lc_numeric():
if all(stamp is None for stamp in data_indexes):
for value in data_values:
@@ -397,7 +427,7 @@ def paste(self, _=True):
clipboard = QApplication.clipboard()
mime_data = clipboard.mimeData()
data_formats = mime_data.formats()
- if 'text/plain' not in data_formats:
+ if "text/plain" not in data_formats:
return False
try:
pasted_table = self._read_pasted_text(clipboard.text())
@@ -437,7 +467,7 @@ def _read_pasted_text(text):
list of float: A list of floats
"""
with io.StringIO(text) as input_stream:
- reader = csv.reader(input_stream, delimiter='\t')
+ reader = csv.reader(input_stream, delimiter="\t")
with system_lc_numeric():
return [locale.atof(row[0]) for row in reader if row]
@@ -474,7 +504,7 @@ def paste(self, _=False):
clipboard = QApplication.clipboard()
mime_data = clipboard.mimeData()
data_formats = mime_data.formats()
- if 'text/plain' not in data_formats:
+ if "text/plain" not in data_formats:
return False
try:
pasted_table = self._read_pasted_text(clipboard.text())
@@ -571,7 +601,7 @@ def _read_pasted_text(text):
tuple: a tuple (data indexes, data values)
"""
with io.StringIO(text) as input_stream:
- reader = csv.reader(input_stream, delimiter='\t')
+ reader = csv.reader(input_stream, delimiter="\t")
single_column = list()
data_indexes = list()
data_values = list()
@@ -620,7 +650,7 @@ def copy(self, _=False):
row_values["x" if index.column() == 0 else "y"] = index.data(Qt.ItemDataRole.EditRole)
with system_lc_numeric():
with io.StringIO() as output:
- writer = csv.writer(output, delimiter='\t')
+ writer = csv.writer(output, delimiter="\t")
for row_values in values.values():
x = row_values.get("x", "")
y = row_values.get("y", "")
@@ -630,7 +660,7 @@ def copy(self, _=False):
else:
with system_lc_numeric():
with io.StringIO() as output:
- writer = csv.writer(output, delimiter='\t')
+ writer = csv.writer(output, delimiter="\t")
for index in selected_indexes:
y = index.data(Qt.ItemDataRole.EditRole)
writer.writerow([locale.str(y) if isinstance(y, Number) else y])
@@ -646,7 +676,7 @@ def paste(self, _=False):
clipboard = QApplication.clipboard()
mime_data = clipboard.mimeData()
data_formats = mime_data.formats()
- if 'text/plain' not in data_formats:
+ if "text/plain" not in data_formats:
return False
try:
pasted_table = self._read_pasted_text(clipboard.text())
@@ -691,7 +721,7 @@ def _read_pasted_text(text):
list of str: data column
"""
with io.StringIO(text) as input_stream:
- reader = csv.reader(input_stream, delimiter='\t')
+ reader = csv.reader(input_stream, delimiter="\t")
column = [row[0] for row in reader if row]
return column
@@ -766,7 +796,7 @@ def paste(self, _=False):
clipboard = QApplication.clipboard()
mime_data = clipboard.mimeData()
data_formats = mime_data.formats()
- if 'text/plain' not in data_formats:
+ if "text/plain" not in data_formats:
return False
pasted_table = self._read_pasted_text(clipboard.text())
paste_length = len(pasted_table)
@@ -811,7 +841,7 @@ def _read_pasted_text(text):
"""
data = list()
with io.StringIO(text) as input_stream:
- reader = csv.reader(input_stream, delimiter='\t')
+ reader = csv.reader(input_stream, delimiter="\t")
with system_lc_numeric():
for row in reader:
data_row = list()
diff --git a/spinetoolbox/widgets/custom_qtextbrowser.py b/spinetoolbox/widgets/custom_qtextbrowser.py
index f2c589ea2..0505fa04c 100644
--- a/spinetoolbox/widgets/custom_qtextbrowser.py
+++ b/spinetoolbox/widgets/custom_qtextbrowser.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Class for a custom QTextBrowser for showing the logs and tool output.
-"""
-
+"""Class for a custom QTextBrowser for showing the logs and tool output."""
from contextlib import contextmanager
from PySide6.QtCore import Slot
from PySide6.QtGui import QTextCursor, QFontDatabase, QTextBlockFormat, QTextFrameFormat, QBrush, QAction, QPalette
@@ -124,9 +122,9 @@ def _select_execution(self, action):
@staticmethod
def _make_log_entry_title(title):
- return f'{title}'
+ return f"{title}"
- def start_execution(self, timestamp):
+ def make_log_entry_point(self, timestamp):
"""Creates cursors (log entry points) for given items in event log.
Args:
diff --git a/spinetoolbox/widgets/custom_qtreeview.py b/spinetoolbox/widgets/custom_qtreeview.py
index 72f974882..178b609b4 100644
--- a/spinetoolbox/widgets/custom_qtreeview.py
+++ b/spinetoolbox/widgets/custom_qtreeview.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Classes for custom QTreeView.
-"""
-
+"""Classes for custom QTreeView."""
import os
from PySide6.QtWidgets import QTreeView, QApplication
from PySide6.QtCore import Signal, Qt
diff --git a/spinetoolbox/widgets/custom_qwidgets.py b/spinetoolbox/widgets/custom_qwidgets.py
index 469c1ea76..4cc93c7bb 100644
--- a/spinetoolbox/widgets/custom_qwidgets.py
+++ b/spinetoolbox/widgets/custom_qwidgets.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Custom QWidgets for Filtering and Zooming.
-"""
-
+"""Custom QWidgets for Filtering and Zooming."""
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
@@ -119,13 +117,13 @@ def __init__(self, parent, make_filter_model, *args, **kwargs):
# parameters
self._filter_state = set()
self._filter_empty_state = None
- self._search_text = ''
+ self._search_text = ""
self.search_delay = 200
# create ui elements
self._ui_vertical_layout = QVBoxLayout(self)
self._ui_list = QListView()
self._ui_edit = QLineEdit()
- self._ui_edit.setPlaceholderText('Search')
+ self._ui_edit.setPlaceholderText("Search")
self._ui_edit.setClearButtonEnabled(True)
self._ui_buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok)
self._ui_vertical_layout.addWidget(self._ui_edit)
@@ -134,6 +132,7 @@ def __init__(self, parent, make_filter_model, *args, **kwargs):
self._search_timer = QTimer() # Used to limit search so it doesn't search when typing
self._search_timer.setSingleShot(True)
self._filter_model = make_filter_model(*args, **kwargs)
+ self._filter_model.setParent(self)
self._filter_model.set_list(self._filter_state)
self._ui_list.setModel(self._filter_model)
self.connect_signals()
@@ -173,14 +172,14 @@ def _apply_filter(self):
"""Apply current filter and save state."""
self._filter_model.apply_filter()
self.save_state()
- self._ui_edit.setText('')
+ self._ui_edit.setText("")
self.okPressed.emit()
def _cancel_filter(self):
"""Cancel current edit of filter and set the state to the stored state."""
self._filter_model.remove_filter()
self.reset_state()
- self._ui_edit.setText('')
+ self._ui_edit.setText("")
self.cancelPressed.emit()
def _filter_list(self):
@@ -345,7 +344,7 @@ def _align_buttons(self):
for i in range(layout.count()):
item = layout.itemAt(i)
if item.widget() in self._buttons:
- layout.itemAt(i).setAlignment(Qt.AlignBottom)
+ item.setAlignment(Qt.AlignBottom)
def add_frame(self, left, right, title):
"""Add frame around given actions, with given title.
@@ -383,7 +382,7 @@ def sizeHint(self):
"""Make room for frames if needed."""
size = super().sizeHint()
if self._frames:
- size = QSize(size.width(), size.height() + self.fontMetrics().height())
+ size.setHeight(size.height() + self.fontMetrics().height())
return size
def paintEvent(self, ev):
@@ -543,7 +542,7 @@ class _ExecutionManager:
def __set_name__(self, owner, name):
self.public_name = name
- self.private_name = '_' + name
+ self.private_name = "_" + name
def __get__(self, obj, objtype=None):
return getattr(obj, self.private_name)
@@ -634,7 +633,7 @@ def __init__(self, text="", parent=None):
font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
line_edit.setFont(font)
button = QToolButton()
- font = QFont('Font Awesome 5 Free Solid')
+ font = QFont("Font Awesome 5 Free Solid")
button.setFont(font)
button.setText("\uf0c5")
button.setToolTip("Copy text")
@@ -676,6 +675,12 @@ def setMinimum(self, minimum):
except TypeError:
pass
+ def setMaximum(self, maximum):
+ try:
+ self._validator.setTop(maximum)
+ except TypeError:
+ pass
+
@Slot(str)
def setValue(self, value, strict=False):
try:
@@ -764,19 +769,3 @@ def _handle_check_box_state_changed(self, _checked):
class PurgeSettingsDialog(SelectDatabaseItemsDialog):
_ok_button_can_be_disabled = False
-
-
-class ResizingViewMixin:
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self._resizing_timer = QTimer()
- self._resizing_timer.setSingleShot(True)
- self._resizing_timer.setInterval(20)
- self._resizing_timer.timeout.connect(self._do_resize)
-
- def rowsInserted(self, parent, start, end):
- super().rowsInserted(parent, start, end)
- self._resizing_timer.start()
-
- def _do_resize(self):
- raise NotImplementedError()
diff --git a/spinetoolbox/widgets/datetime_editor.py b/spinetoolbox/widgets/datetime_editor.py
index 99cf0bf51..9118b80f5 100644
--- a/spinetoolbox/widgets/datetime_editor.py
+++ b/spinetoolbox/widgets/datetime_editor.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-An editor widget for editing datetime database (relationship) parameter values.
-"""
-
+"""An editor widget for editing datetime database (relationship) parameter values."""
from datetime import datetime
from PySide6.QtCore import QDate, QDateTime, QTime, Slot
from PySide6.QtWidgets import QWidget
diff --git a/spinetoolbox/widgets/duration_editor.py b/spinetoolbox/widgets/duration_editor.py
index 0dbd662bd..8e13e8b93 100644
--- a/spinetoolbox/widgets/duration_editor.py
+++ b/spinetoolbox/widgets/duration_editor.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-An editor widget for editing duration database (relationship) parameter values.
-"""
-
+"""An editor widget for editing duration database (relationship) parameter values."""
from PySide6.QtCore import Slot
from PySide6.QtWidgets import QWidget
from spinedb_api import Duration, duration_to_relativedelta, ParameterValueFormatError
diff --git a/spinetoolbox/widgets/indexed_value_table_context_menu.py b/spinetoolbox/widgets/indexed_value_table_context_menu.py
index 514c021a0..000adc432 100644
--- a/spinetoolbox/widgets/indexed_value_table_context_menu.py
+++ b/spinetoolbox/widgets/indexed_value_table_context_menu.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,11 +10,8 @@
# this program. If not, see .
######################################################################################################################
-"""
-Context menus for parameter value editor widgets.
-"""
+"""Context menus for parameter value editor widgets."""
from operator import itemgetter
-
from PySide6.QtCore import Slot
from PySide6.QtWidgets import QInputDialog, QMenu
from PySide6.QtGui import QAction
diff --git a/spinetoolbox/widgets/install_julia_wizard.py b/spinetoolbox/widgets/install_julia_wizard.py
index 378c6b61f..5344b56a5 100644
--- a/spinetoolbox/widgets/install_julia_wizard.py
+++ b/spinetoolbox/widgets/install_julia_wizard.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Classes for custom QDialogs for julia setup.
-"""
-
+"""Classes for custom QDialogs for julia setup."""
import os
from enum import IntEnum, auto
@@ -34,7 +32,7 @@
)
from PySide6.QtCore import Signal, Slot, Qt
from PySide6.QtGui import QCursor
-from spine_engine.utils.helpers import resolve_python_interpreter
+from spine_engine.utils.helpers import resolve_current_python_interpreter
from ..execution_managers import QProcessExecutionManager
from ..config import APPLICATION_PATH
from .custom_qwidgets import HyperTextLabel, QWizardProcessPage, LabelWithCopyButton
@@ -74,12 +72,12 @@ def __init__(self, parent):
def set_julia_exe(self):
basename = next(
- (file for file in os.listdir(self.field('symlink_dir')) if file.lower().startswith("julia")), None
+ (file for file in os.listdir(self.field("symlink_dir")) if file.lower().startswith("julia")), None
)
if basename is None:
self.julia_exe = None
return
- self.julia_exe = os.path.join(self.field('symlink_dir'), basename)
+ self.julia_exe = os.path.join(self.field("symlink_dir"), basename)
def accept(self):
super().accept()
@@ -91,7 +89,7 @@ class JillNotFoundPage(QWizardPage):
def __init__(self, parent):
super().__init__(parent)
self.setTitle("Unable to find jill")
- conda_env = os.environ.get('CONDA_DEFAULT_ENV', 'base')
+ conda_env = os.environ.get("CONDA_DEFAULT_ENV", "base")
toolbox_dir = os.path.dirname(APPLICATION_PATH)
header = (
"
Spine Toolbox needs the jill package "
@@ -210,7 +208,7 @@ def initializePage(self):
# 1. sys.executable when not frozen
# 2. PATH python if frozen (This fails if no jill installed)
# 3. If no PATH python, uses embedded python /tools/python.exe
- python = resolve_python_interpreter("")
+ python = resolve_current_python_interpreter()
self._exec_mngr = QProcessExecutionManager(self, python, args, semisilent=True)
self.completeChanged.emit()
self._exec_mngr.execution_finished.connect(self._handle_julia_install_finished)
diff --git a/spinetoolbox/widgets/jump_properties_widget.py b/spinetoolbox/widgets/jump_properties_widget.py
index 4fd82b8f6..cd9d7faa2 100644
--- a/spinetoolbox/widgets/jump_properties_widget.py
+++ b/spinetoolbox/widgets/jump_properties_widget.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,9 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains jump properties widget's business logic.
-"""
+"""Contains jump properties widget's business logic."""
from PySide6.QtCore import Slot, QItemSelection
from .properties_widget import PropertiesWidgetBase
from ..project_commands import SetJumpConditionCommand, UpdateJumpCmdLineArgsCommand
@@ -57,25 +56,28 @@ def __init__(self, toolbox, base_color=None):
def _load_condition_into_ui(self, condition):
self._track_changes = False
self._ui.pushButton_save_script.setEnabled(False)
- self._ui.condition_script_edit.setEnabled(condition["type"] == "python-script")
+ self._ui.condition_script_edit.set_enabled_with_greyed(condition["type"] == "python-script")
self._ui.comboBox_tool_spec.setEnabled(condition["type"] == "tool-specification")
self._ui.toolButton_edit_tool_spec.setEnabled(condition["type"] == "tool-specification")
- if not condition["type"] == "python-script":
- self._ui.condition_script_edit.clear()
if condition["type"] == "python-script":
self._ui.radioButton_py_script.setChecked(True)
- if not condition["script"] or condition["script"] != self._ui.condition_script_edit.toPlainText():
- self._ui.condition_script_edit.setPlainText(condition["script"])
+ self._ui.condition_script_edit.setPlainText(condition["script"])
elif condition["type"] == "tool-specification":
self._ui.radioButton_tool_spec.setChecked(True)
self._ui.comboBox_tool_spec.setCurrentText(condition["specification"])
self._track_changes = True
def _make_condition_from_ui(self):
+ condition = {
+ "script": self._ui.condition_script_edit.toPlainText(),
+ "specification": self._ui.comboBox_tool_spec.currentText(),
+ }
if self._ui.radioButton_py_script.isChecked():
- return {"type": "python-script", "script": self._ui.condition_script_edit.toPlainText()}
+ condition["type"] = "python-script"
+ return condition
if self._ui.radioButton_tool_spec.isChecked():
- return {"type": "tool-specification", "specification": self._ui.comboBox_tool_spec.currentText()}
+ condition["type"] = "tool-specification"
+ return condition
return {}
def _change_condition(self):
@@ -84,7 +86,7 @@ def _change_condition(self):
return
condition = self._make_condition_from_ui()
if self._jump.condition != condition:
- self._toolbox.undo_stack.push(SetJumpConditionCommand(self, self._jump, condition))
+ self._toolbox.undo_stack.push(SetJumpConditionCommand(self._toolbox.project(), self._jump, self, condition))
@Slot(bool)
def _show_tool_spec_form(self, _checked=False):
@@ -121,7 +123,9 @@ def _populate_cmd_line_args_model(self):
@Slot(list)
def _push_update_cmd_line_args_command(self, cmd_line_args):
if self._jump.cmd_line_args != cmd_line_args:
- self._toolbox.undo_stack.push(UpdateJumpCmdLineArgsCommand(self, self._jump, cmd_line_args))
+ self._toolbox.undo_stack.push(
+ UpdateJumpCmdLineArgsCommand(self._toolbox.project(), self._jump, self, cmd_line_args)
+ )
@Slot(bool)
def _remove_arg(self, _=False):
diff --git a/spinetoolbox/widgets/jupyter_console_widget.py b/spinetoolbox/widgets/jupyter_console_widget.py
index ce132de5c..58db5f58e 100644
--- a/spinetoolbox/widgets/jupyter_console_widget.py
+++ b/spinetoolbox/widgets/jupyter_console_widget.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Class for a custom RichJupyterWidget that can run Tool instances.
-"""
-
+"""Class for a custom RichJupyterWidget that can run Tool instances."""
import logging
import multiprocessing
from queue import Empty
@@ -52,7 +50,10 @@ def __init__(self, toolbox, kernel_name, owner=None):
super().__init__()
self._toolbox = toolbox
self.kernel_name = kernel_name
+ self.sysimage_path = None
self.owners = {owner}
+ if owner is not None:
+ self.sysimage_path = owner._options.get("julia_sysimage", None)
self.kernel_client = None
self._connection_file = None
self._execution_manager = None
@@ -61,7 +62,7 @@ def __init__(self, toolbox, kernel_name, owner=None):
self._q = multiprocessing.Queue()
self._logger = QueueLogger(self._q, "DetachedPythonConsole", None, dict())
self.normal_cursor = self._control.viewport().cursor()
- self._copy_input_action = QAction('Copy (Only Input)', self)
+ self._copy_input_action = QAction("Copy (Only Input)", self)
self._copy_input_action.triggered.connect(lambda checked: self.copy_input())
self._copy_input_action.setEnabled(False)
self.copy_available.connect(self._copy_input_action.setEnabled)
@@ -90,6 +91,7 @@ def request_start_kernel(self, conda=False):
server_ip="127.0.0.1",
environment=environment,
conda_exe=conda_exe,
+ extra_switches=self.sysimage_path,
)
try:
msg_type, msg = self._q.get(timeout=20) # Blocks until msg (tuple(str, dict) is received, or timeout.
@@ -250,7 +252,7 @@ def _context_menu_make(self, pos):
"""Reimplemented to add actions to console context-menus."""
menu = super()._context_menu_make(pos)
for before_action in menu.actions():
- if before_action.text() == 'Copy (Raw Text)':
+ if before_action.text() == "Copy (Raw Text)":
menu.insertAction(before_action, self._copy_input_action)
break
first_action = menu.actions()[0]
@@ -277,15 +279,19 @@ def copy_input(self):
if m:
useful_lines.append(line[len(m.group(0)) :])
continue
- text = '\n'.join(useful_lines)
+ text = "\n".join(useful_lines)
try:
- was_newline = text[-1] == '\n'
+ was_newline = text[-1] == "\n"
except IndexError:
was_newline = False
if was_newline: # user doesn't need newline
text = text[:-1]
QApplication.clipboard().setText(text)
+ def _show_interpreter_prompt(self, number=None):
+ if self.kernel_client is not None:
+ super()._show_interpreter_prompt(number)
+
def closeEvent(self, e):
"""Catches close event to shut down the kernel client
and sends a signal to Toolbox to request Spine Engine
diff --git a/spinetoolbox/widgets/kernel_editor.py b/spinetoolbox/widgets/kernel_editor.py
index 4d440ff93..4bfb628df 100644
--- a/spinetoolbox/widgets/kernel_editor.py
+++ b/spinetoolbox/widgets/kernel_editor.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,15 +10,13 @@
# this program. If not, see .
######################################################################################################################
-"""
-Widget for showing the progress of making a Julia or Python kernel.
-"""
+"""Widget for showing the progress of making a Julia or Python kernel."""
import subprocess
from PySide6.QtWidgets import QDialog, QMessageBox, QDialogButtonBox, QWidget
from PySide6.QtCore import Slot, Qt, QTimer
from PySide6.QtGui import QGuiApplication, QIcon
from jupyter_client.kernelspec import find_kernel_specs
-from spine_engine.utils.helpers import resolve_python_interpreter, resolve_julia_executable
+from spine_engine.utils.helpers import resolve_current_python_interpreter, resolve_default_julia_executable
from spinetoolbox.execution_managers import QProcessExecutionManager
from spinetoolbox.helpers import (
busy_effect,
@@ -187,8 +186,8 @@ def is_package_installed(python_path, package_name):
Returns:
(bool): True if installed, False if not
"""
- response = subprocess.check_output([python_path, '-m', 'pip', 'freeze', '-q'])
- installed_packages = [r.decode().split('==')[0] for r in response.split()]
+ response = subprocess.check_output([python_path, "-m", "pip", "freeze", "-q"])
+ installed_packages = [r.decode().split("==")[0] for r in response.split()]
return package_name in installed_packages
@busy_effect
@@ -565,16 +564,13 @@ class MiniPythonKernelEditor(KernelEditorBase):
the constructor, then calling ``make_kernel`` starts the process.
"""
- """A reduced version of KernelEditor that basically just takes care of installing one Python kernel.
- The python exe is passed in the constructor, then calling ``make_kernel`` starts the process.
- """
-
def __init__(self, parent, python_exe):
super().__init__(parent, "python")
self.ui.label_message.setText("Finalizing Python configuration... ")
self.ui.stackedWidget.setCurrentIndex(0)
self.setWindowTitle("Python Kernel Specification Creator")
- python_exe = resolve_python_interpreter(python_exe)
+ if not python_exe:
+ python_exe = resolve_current_python_interpreter()
self.ui.lineEdit_python_interpreter.setText(python_exe)
self.python_exe = python_exe
self._kernel_name = "python_kernel" # Fallback name
@@ -626,7 +622,8 @@ def __init__(self, parent, julia_exe, julia_project):
self.ui.label_message.setText("Finalizing Julia configuration... ")
self.ui.stackedWidget.setCurrentIndex(1)
self.setWindowTitle("Julia Kernel Specification Creator")
- julia_exe = resolve_julia_executable(julia_exe)
+ if not julia_exe:
+ julia_exe = resolve_default_julia_executable()
self.ui.lineEdit_julia_executable.setText(julia_exe)
self.ui.lineEdit_julia_project.setText(julia_project)
self._kernel_name = "julia" # This is a prefix, IJulia decides the final kernel name
diff --git a/spinetoolbox/widgets/link_properties_widget.py b/spinetoolbox/widgets/link_properties_widget.py
index 70b5faa28..bac2a3fa0 100644
--- a/spinetoolbox/widgets/link_properties_widget.py
+++ b/spinetoolbox/widgets/link_properties_widget.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,17 +10,18 @@
# this program. If not, see .
######################################################################################################################
-"""
-Link properties widget.
-"""
-
+"""Link properties widget."""
from PySide6.QtCore import Slot
from PySide6.QtWidgets import QMenu
from spinedb_api.filters.scenario_filter import SCENARIO_FILTER_TYPE
-from spinedb_api.filters.tool_filter import TOOL_FILTER_TYPE
from .properties_widget import PropertiesWidgetBase
from .custom_qwidgets import PurgeSettingsDialog
-from ..project_commands import SetConnectionOptionsCommand, SetConnectionDefaultFilterOnlineStatus
+from ..mvcmodels.resource_filter_model import ResourceFilterModel
+from ..project_commands import (
+ SetConnectionFilterTypeEnabled,
+ SetConnectionOptionsCommand,
+ SetConnectionDefaultFilterOnlineStatus,
+)
class LinkPropertiesWidget(PropertiesWidgetBase):
@@ -37,6 +39,9 @@ def __init__(self, toolbox, base_color=None):
self._purge_settings_dialog = None
self.ui = Ui_Form()
self.ui.setupUi(self)
+ self.ui.filter_type_combo_box.addItems(sorted(ResourceFilterModel.FILTER_TYPES))
+ self.ui.filter_type_combo_box.setCurrentText(ResourceFilterModel.FILTER_TYPE_TO_TEXT[SCENARIO_FILTER_TYPE])
+ self.ui.filter_type_combo_box.currentTextChanged.connect(self._select_mutually_exclusive_filter)
self._filter_validation_menu = QMenu(self)
self._filter_validation_actions = self._populate_filter_validation_menu()
self.ui.auto_check_filters_check_box.clicked.connect(self._handle_auto_check_filters_state_changed)
@@ -63,7 +68,16 @@ def set_link(self, connection):
self._toolbox.label_item_name.setText(f"Link {self._connection.link.name}")
self.load_connection_options()
may_have_filters = self._connection.may_have_filters()
+ self.ui.filter_type_combo_box.setEnabled(may_have_filters)
self.ui.treeView_filters.setEnabled(may_have_filters)
+ if may_have_filters:
+ for filter_type, filter_text in ResourceFilterModel.FILTER_TYPE_TO_TEXT.items():
+ filter_type_enabled = self._connection.is_filter_type_enabled(filter_type)
+ self._set_filter_type_expanded(filter_type, filter_type_enabled)
+ if filter_type_enabled:
+ self.ui.filter_type_combo_box.blockSignals(True)
+ self.ui.filter_type_combo_box.setCurrentText(filter_text)
+ self.ui.filter_type_combo_box.blockSignals(False)
self.ui.auto_check_filters_check_box.setChecked(self._connection.is_filter_online_by_default)
self.ui.auto_check_filters_check_box.setEnabled(may_have_filters)
self.ui.open_filter_validation_menu_button.setEnabled(may_have_filters)
@@ -104,10 +118,7 @@ def _populate_filter_validation_menu(self):
Returns:
dict: menu actions
"""
- action_data = {
- "Require at least one checked scenario": SCENARIO_FILTER_TYPE,
- "Require at least one checked tool": TOOL_FILTER_TYPE,
- }
+ action_data = {"Require at least one checked scenario": SCENARIO_FILTER_TYPE}
actions = {}
for label, filter_type in action_data.items():
action = self._filter_validation_menu.addAction(label)
@@ -194,3 +205,45 @@ def load_connection_options(self):
self.ui.checkBox_purge_before_writing.setChecked(self._connection.purge_before_writing)
self.ui.purge_settings_button.setEnabled(self._connection.purge_before_writing)
self.ui.spinBox_write_index.setValue(self._connection.write_index)
+
+ @Slot(str)
+ def _select_mutually_exclusive_filter(self, label):
+ enabled_filter_type = ResourceFilterModel.FILTER_TYPES[label]
+ disabled_filter_types = set(ResourceFilterModel.FILTER_TYPES.values()) - {enabled_filter_type}
+ self._toolbox.undo_stack.beginMacro(f"enable {label}s on connection {self._connection.link.name}")
+ for disabled_type in disabled_filter_types:
+ self._toolbox.undo_stack.push(
+ SetConnectionFilterTypeEnabled(self._toolbox.project(), self._connection, disabled_type, False)
+ )
+ self._toolbox.undo_stack.push(
+ SetConnectionFilterTypeEnabled(self._toolbox.project(), self._connection, enabled_filter_type, True)
+ )
+ self._toolbox.undo_stack.endMacro()
+
+ def set_filter_type_enabled(self, filter_type, enabled):
+ """Enables or disables filter type in the tree.
+
+ Args:
+ filter_type (str): filter type
+ enabled (bool): whether filter type is enabled
+ """
+ self._set_filter_type_expanded(filter_type, enabled)
+ if not enabled:
+ return
+ filter_type_text = ResourceFilterModel.FILTER_TYPE_TO_TEXT.get(filter_type)
+ if filter_type_text is None:
+ return
+ self.ui.filter_type_combo_box.blockSignals(True)
+ self.ui.filter_type_combo_box.setCurrentText(filter_type_text)
+ self.ui.filter_type_combo_box.blockSignals(False)
+
+ def _set_filter_type_expanded(self, filter_type, expanded):
+ """Expands or collapses filter type branch in the tree.
+
+ Args:
+ filter_type (str): filter type
+ expanded (bool): True to expand the branch, False to collapse
+ """
+ action = self.ui.treeView_filters.expand if expanded else self.ui.treeView_filters.collapse
+ for item in self._connection.resource_filter_model.filter_type_items(filter_type):
+ action(item.index())
diff --git a/spinetoolbox/widgets/map_editor.py b/spinetoolbox/widgets/map_editor.py
index 60a623aa9..9dee83630 100644
--- a/spinetoolbox/widgets/map_editor.py
+++ b/spinetoolbox/widgets/map_editor.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,13 +10,9 @@
# this program. If not, see .
######################################################################################################################
-"""
-An editor widget for editing a map type parameter values.
-"""
-
+"""An editor widget for editing a map type parameter values."""
from PySide6.QtCore import QModelIndex, QPoint, Qt, Slot
from PySide6.QtWidgets import QWidget
-
from spinedb_api import Map
from ..helpers import inquire_index_name
from .map_value_editor import MapValueEditor
@@ -25,14 +22,13 @@
class MapEditor(QWidget):
- """
- A widget for editing maps.
-
- Attributes:
- parent (QWidget):
- """
+ """A widget for editing maps."""
def __init__(self, parent=None):
+ """
+ Args:
+ parent (QWidget, optional): parent widget
+ """
from ..ui.map_editor import Ui_MapEditor # pylint: disable=import-outside-toplevel
super().__init__(parent)
@@ -67,6 +63,7 @@ def _show_table_context_menu(self, position):
def set_value(self, value):
"""Sets the parameter_value to be edited."""
self._model.reset(value)
+ self._ui.map_table_view.resizeColumnsToContents()
def value(self):
"""Returns the parameter_value currently being edited."""
diff --git a/spinetoolbox/widgets/map_value_editor.py b/spinetoolbox/widgets/map_value_editor.py
index 888dd4c3d..4d74bee2f 100644
--- a/spinetoolbox/widgets/map_value_editor.py
+++ b/spinetoolbox/widgets/map_value_editor.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,9 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-An editor dialog for map indexes and values.
-"""
+"""An editor dialog for map indexes and values."""
from PySide6.QtCore import Qt
from .array_editor import ArrayEditor
from .duration_editor import DurationEditor
diff --git a/spinetoolbox/widgets/multi_tab_spec_editor.py b/spinetoolbox/widgets/multi_tab_spec_editor.py
index c6bbedf42..e72ccbd9d 100644
--- a/spinetoolbox/widgets/multi_tab_spec_editor.py
+++ b/spinetoolbox/widgets/multi_tab_spec_editor.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains the MultiTabSpecEditor class.
-"""
-
+"""Contains the MultiTabSpecEditor class."""
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QMenu
diff --git a/spinetoolbox/widgets/multi_tab_window.py b/spinetoolbox/widgets/multi_tab_window.py
index 826adc5ba..d7da88f50 100644
--- a/spinetoolbox/widgets/multi_tab_window.py
+++ b/spinetoolbox/widgets/multi_tab_window.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains the MultiTabWindow and TabBarPlus classes.
-"""
-
+"""Contains the MultiTabWindow and TabBarPlus classes."""
from PySide6.QtWidgets import QMainWindow, QTabWidget, QWidget, QTabBar, QToolButton, QApplication, QMenu
from PySide6.QtCore import Qt, Slot, QPoint, Signal, QEvent
from PySide6.QtGui import QGuiApplication, QCursor, QIcon, QMouseEvent
@@ -64,7 +62,7 @@ def others(self):
"""List of other MultiTabWindows of the same type.
Returns:
- list of MultiTabWindow: other MutliTabWindows windows
+ list of MultiTabWindow: other MultiTabWindows windows
"""
return [w for w in self._other_editor_windows[type(self).__name__] if w is not self]
@@ -78,7 +76,7 @@ def _make_new_tab(self, *args, **kwargs):
raise NotImplementedError()
def show_plus_button_context_menu(self, global_pos):
- """Opens a context menu for the tool bar.
+ """Opens a context menu for the toolbar.
Args:
global_pos (QPoint): menu position on screen
@@ -125,11 +123,17 @@ def add_new_tab(self, *args, **kwargs):
Args:
*args: parameters forwarded to :func:`MutliTabWindow._make_new_tab`
**kwargs: parameters forwarded to :func:`MultiTabwindow._make_new_tab`
+
+ Returns:
+ bool: True if successful, False otherwise
"""
if not self._accepting_new_tabs:
- return
+ return False
tab = self._make_new_tab(*args, **kwargs)
+ if not tab:
+ return False
self._add_connect_tab(tab, self.new_tab_title)
+ return True
def insert_new_tab(self, index, *args, **kwargs):
"""Creates a new tab and inserts it at the given index.
@@ -410,7 +414,7 @@ def restore_ui(self):
window_size = self.qsettings.value("windowSize")
window_pos = self.qsettings.value("windowPosition")
window_state = self.qsettings.value("windowState")
- window_maximized = self.qsettings.value("windowMaximized", defaultValue='false')
+ window_maximized = self.qsettings.value("windowMaximized", defaultValue="false")
n_screens = self.qsettings.value("n_screens", defaultValue=1)
self.qsettings.endGroup()
original_size = self.size()
@@ -424,7 +428,7 @@ def restore_ui(self):
# There are less screens available now than on previous application startup
self.move(0, 0) # Move this widget to primary screen position (0,0)
ensure_window_is_on_screen(self, original_size)
- if window_maximized == 'true':
+ if window_maximized == "true":
self.setWindowState(Qt.WindowMaximized)
def save_window_state(self):
diff --git a/spinetoolbox/widgets/notification.py b/spinetoolbox/widgets/notification.py
index 4d48b2a7f..3e0ac08a4 100644
--- a/spinetoolbox/widgets/notification.py
+++ b/spinetoolbox/widgets/notification.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains a notification widget.
-"""
-
+"""Contains a notification widget."""
from PySide6.QtWidgets import QFrame, QLabel, QHBoxLayout, QGraphicsOpacityEffect, QLayout, QSizePolicy, QPushButton
from PySide6.QtCore import Qt, Slot, QTimer, QPropertyAnimation, Property, QObject
from PySide6.QtGui import QFont, QColor
diff --git a/spinetoolbox/widgets/open_project_widget.py b/spinetoolbox/widgets/open_project_dialog.py
similarity index 96%
rename from spinetoolbox/widgets/open_project_widget.py
rename to spinetoolbox/widgets/open_project_dialog.py
index d57196c91..25741050c 100644
--- a/spinetoolbox/widgets/open_project_widget.py
+++ b/spinetoolbox/widgets/open_project_dialog.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains a class for a widget that represents a 'Open Project Directory' dialog.
-"""
-
+"""Contains a class for a widget that represents a 'Open Project Directory' dialog."""
import os
from PySide6.QtWidgets import QDialog, QFileSystemModel, QAbstractItemView, QComboBox
from PySide6.QtCore import Qt, Slot, QDir, QStandardPaths, QModelIndex
@@ -35,7 +33,7 @@ def __init__(self, toolbox):
from ..ui import open_project_dialog # pylint: disable=import-outside-toplevel
super().__init__(parent=toolbox, f=Qt.Dialog) # Setting the parent inherits the stylesheet
- self._toolbox = toolbox
+ self._qsettings = toolbox.qsettings()
# Set up the user interface from Designer file
self.ui = open_project_dialog.Ui_Dialog()
self.ui.setupUi(self)
@@ -67,7 +65,7 @@ def __init__(self, toolbox):
self.ui.comboBox_current_path.setValidator(self.validator)
self.ui.comboBox_current_path.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
# Read recent project directories and populate combobox
- recents = self._toolbox.qsettings().value("appSettings/recentProjectStorages", defaultValue=None)
+ recents = self._qsettings.value("appSettings/recentProjectStorages", defaultValue=None)
if recents:
recents_lst = str(recents).split("\n")
self.ui.comboBox_current_path.insertItems(0, recents_lst)
@@ -89,13 +87,13 @@ def __init__(self, toolbox):
def set_keyboard_shortcuts(self):
"""Creates keyboard shortcuts for the 'Root', 'Home', etc. buttons."""
- self.go_root_action.setShortcut(QKeySequence(Qt.Key_F1))
+ self.go_root_action.setShortcut(QKeySequence(Qt.Key.Key_F1))
self.addAction(self.go_root_action)
- self.go_home_action.setShortcut(QKeySequence(Qt.Key_F2))
+ self.go_home_action.setShortcut(QKeySequence(Qt.Key.Key_F2))
self.addAction(self.go_home_action)
- self.go_documents_action.setShortcut(QKeySequence(Qt.Key_F3))
+ self.go_documents_action.setShortcut(QKeySequence(Qt.Key.Key_F3))
self.addAction(self.go_documents_action)
- self.go_desktop_action.setShortcut(QKeySequence(Qt.Key_F4))
+ self.go_desktop_action.setShortcut(QKeySequence(Qt.Key.Key_F4))
self.addAction(self.go_desktop_action)
def connect_signals(self):
@@ -160,7 +158,7 @@ def current_index_changed(self, i):
"""
p = self.ui.comboBox_current_path.itemText(i)
if not os.path.isdir(p):
- self.remove_directory_from_recents(p, self._toolbox.qsettings())
+ self.remove_directory_from_recents(p, self._qsettings)
return
fm_index = self.file_model.index(p)
self.ui.treeView_file_system.collapseAll()
@@ -290,9 +288,7 @@ def done(self, r):
return
# self.selection() now contains a valid Spine Toolbox project directory.
# Add the parent directory of selected directory to qsettings
- self.update_recents(
- os.path.abspath(os.path.join(self.selection(), os.path.pardir)), self._toolbox.qsettings()
- )
+ self.update_recents(os.path.abspath(os.path.join(self.selection(), os.path.pardir)), self._qsettings)
super().done(r)
@staticmethod
@@ -357,7 +353,7 @@ def show_context_menu(self, pos):
action = self.combobox_context_menu.get_action()
if action == "Clear history":
self.ui.comboBox_current_path.clear()
- self._toolbox.qsettings().setValue("appSettings/recentProjectStorages", "")
+ self._qsettings.setValue("appSettings/recentProjectStorages", "")
self.go_root()
else: # No option selected
pass
diff --git a/spinetoolbox/widgets/options_dialog.py b/spinetoolbox/widgets/options_dialog.py
new file mode 100644
index 000000000..eab99e4de
--- /dev/null
+++ b/spinetoolbox/widgets/options_dialog.py
@@ -0,0 +1,95 @@
+######################################################################################################################
+# 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 .
+######################################################################################################################
+
+"""Provides OptionsDialog."""
+from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QButtonGroup, QRadioButton, QFrame, QStyle, QDialogButtonBox
+from PySide6.QtCore import Slot, Qt
+
+
+class OptionsDialog(QDialog):
+ """A dialog with options."""
+
+ def __init__(self, parent, title, text, option_to_answer, notes=None, preferred=None):
+ """
+ Args:
+ parent (QWidget): the parent widget
+ title (srt): title of the window
+ text (str): text to show to the user
+ option_to_answer (dict): mapping option string to corresponding answer to return
+ preferred (int,optional): preselected option if any
+ """
+ super().__init__(parent)
+ if notes is None:
+ notes = {}
+ self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False)
+ self.setWindowModality(Qt.ApplicationModal)
+ self.setWindowTitle(title)
+ layout = QVBoxLayout(self)
+ if option_to_answer:
+ text += "
Please select an option:"
+ text_label = QLabel(text)
+ text_label.setWordWrap(True)
+ layout.addWidget(text_label)
+ options_frame = QFrame()
+ options_layout = QVBoxLayout(options_frame)
+ self._button_group = QButtonGroup()
+ for i, o in enumerate(option_to_answer):
+ note = notes.get(o)
+ if i == preferred:
+ o += " (RECOMMENDED)"
+ option_button = QRadioButton(o)
+ options_layout.addWidget(option_button)
+ self._button_group.addButton(option_button, id=i)
+ if note is not None:
+ note_label = QLabel(note)
+ note_label.setWordWrap(True)
+ indent = sum(
+ self.style().pixelMetric(pm)
+ for pm in (
+ QStyle.PixelMetric.PM_ExclusiveIndicatorWidth,
+ QStyle.PixelMetric.PM_RadioButtonLabelSpacing,
+ )
+ )
+ note_label.setIndent(indent)
+ font = note_label.font()
+ font.setPointSize(font.pointSize() - 1)
+ note_label.setFont(font)
+ note_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
+ options_layout.addWidget(note_label)
+ layout.addWidget(options_frame)
+ button_box = QDialogButtonBox(self)
+ self._ok_button = button_box.addButton("Ok", QDialogButtonBox.AcceptRole)
+ button_box.accepted.connect(self.accept)
+ if option_to_answer:
+ button_box.addButton("Cancel", QDialogButtonBox.RejectRole)
+ button_box.rejected.connect(self.reject)
+ layout.addWidget(button_box)
+ if preferred is not None:
+ self._button_group.button(preferred).setChecked(True)
+ self._button_group.idToggled.connect(self._update_ok_button_enabled)
+ self._update_ok_button_enabled()
+
+ @classmethod
+ def get_answer(cls, parent, title, text, option_to_answer, notes=None, preferred=None):
+ obj = cls(parent, title, text, option_to_answer, notes=notes, preferred=preferred)
+ obj.exec()
+ if obj.result() != QDialog.Accepted:
+ return None
+ id_ = obj._button_group.checkedId()
+ if id_ == -1:
+ return None
+ option = list(option_to_answer)[id_]
+ return option_to_answer[option]
+
+ @Slot(int)
+ def _update_ok_button_enabled(self, _id=None):
+ self._ok_button.setEnabled(not self._button_group.buttons() or self._button_group.checkedButton() is not None)
diff --git a/spinetoolbox/widgets/parameter_value_editor.py b/spinetoolbox/widgets/parameter_value_editor.py
index 76d3ca0e9..31037a264 100644
--- a/spinetoolbox/widgets/parameter_value_editor.py
+++ b/spinetoolbox/widgets/parameter_value_editor.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,11 +10,10 @@
# this program. If not, see .
######################################################################################################################
-"""
-An editor dialog for editing database (relationship) parameter values.
-"""
+"""An editor dialog for editing database (relationship) parameter values."""
from PySide6.QtWidgets import QMessageBox
from spinedb_api import ParameterValueFormatError, to_database
+from spinedb_api.parameter_value import deep_copy_value
from .array_editor import ArrayEditor
from .duration_editor import DurationEditor
from .datetime_editor import DatetimeEditor
@@ -54,7 +54,8 @@ def __init__(self, index, parent=None, plain=False):
self._index = index
self.set_data_delayed = model.get_set_data_delayed(index)
self.setWindowTitle(f"Edit value -- {model.index_name(index)} --")
- self._select_editor(index.data(PARSED_ROLE))
+ value = deep_copy_value(index.data(PARSED_ROLE))
+ self._select_editor(value)
def _set_data(self, value):
"""See base class."""
diff --git a/spinetoolbox/widgets/parameter_value_editor_base.py b/spinetoolbox/widgets/parameter_value_editor_base.py
index 1901a19f1..53adb3259 100644
--- a/spinetoolbox/widgets/parameter_value_editor_base.py
+++ b/spinetoolbox/widgets/parameter_value_editor_base.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-A base for editor windows for editing parameter values.
-"""
-
+"""A base for editor windows for editing parameter values."""
from enum import auto, Enum, unique
from numbers import Number
from PySide6.QtCore import Qt, Slot
diff --git a/spinetoolbox/widgets/persistent_console_widget.py b/spinetoolbox/widgets/persistent_console_widget.py
index f917c0170..091f64ecf 100644
--- a/spinetoolbox/widgets/persistent_console_widget.py
+++ b/spinetoolbox/widgets/persistent_console_widget.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,6 +10,7 @@
# this program. If not, see .
######################################################################################################################
+"""Contains a widget acting as a console for Julia & Python REPL's."""
import os
import uuid
from pygments.styles import get_style_by_name
@@ -381,8 +383,8 @@ def _flush_text_buffer(self):
if self._text_buffer:
address = uuid.uuid4().hex
char_format = cursor.charFormat()
- char_format.setBackground(QColor('white'))
- char_format.setForeground(QColor('blue'))
+ char_format.setBackground(QColor("white"))
+ char_format.setForeground(QColor("blue"))
char_format.setAnchor(True)
char_format.setAnchorHref(address)
self._skipped[address] = self._text_buffer[-self._MAX_LINES_COUNT :]
diff --git a/spinetoolbox/widgets/plain_parameter_value_editor.py b/spinetoolbox/widgets/plain_parameter_value_editor.py
index 0e409083a..2bb2353da 100644
--- a/spinetoolbox/widgets/plain_parameter_value_editor.py
+++ b/spinetoolbox/widgets/plain_parameter_value_editor.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,10 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-An editor widget for editing plain number database (relationship) parameter values.
-"""
-
+"""An editor widget for editing plain number database (relationship) parameter values."""
from PySide6.QtCore import Slot
from PySide6.QtWidgets import QWidget
from spinetoolbox.helpers import try_number_from_string
diff --git a/spinetoolbox/widgets/plot_canvas.py b/spinetoolbox/widgets/plot_canvas.py
index 86f3b3c3a..011ae1945 100644
--- a/spinetoolbox/widgets/plot_canvas.py
+++ b/spinetoolbox/widgets/plot_canvas.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,9 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-A Qt widget to use as a matplotlib backend.
-"""
+"""A Qt widget to use as a matplotlib backend."""
from enum import auto, Enum, unique
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
from matplotlib.figure import Figure
diff --git a/spinetoolbox/widgets/plot_widget.py b/spinetoolbox/widgets/plot_widget.py
index a14555aac..4deace960 100644
--- a/spinetoolbox/widgets/plot_widget.py
+++ b/spinetoolbox/widgets/plot_widget.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,14 +10,10 @@
# this program. If not, see .
######################################################################################################################
-"""
-A Qt widget showing a toolbar and a matplotlib plotting canvas.
-"""
-
+"""A Qt widget showing a toolbar and a matplotlib plotting canvas."""
import itertools
import io
import csv
-
import numpy
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolBar
from PySide6.QtCore import QMetaObject, Qt
diff --git a/spinetoolbox/widgets/plugin_manager_widgets.py b/spinetoolbox/widgets/plugin_manager_widgets.py
index 3fafd1f9f..cfa4f8dbf 100644
--- a/spinetoolbox/widgets/plugin_manager_widgets.py
+++ b/spinetoolbox/widgets/plugin_manager_widgets.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,9 +10,7 @@
# this program. If not, see .
######################################################################################################################
-"""
-Contains PluginManager dialogs and widgets.
-"""
+"""Contains PluginManager dialogs and widgets."""
from PySide6.QtCore import Qt, Slot, Signal, QSortFilterProxyModel, QTimer, QSize
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLineEdit, QListView, QDialogButtonBox
from PySide6.QtGui import QStandardItemModel, QStandardItem
@@ -37,7 +36,7 @@ def __init__(self, parent):
"""Initialize class"""
super().__init__(parent)
self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False)
- self.setWindowTitle('Install plugin')
+ self.setWindowTitle("Install plugin")
QVBoxLayout(self)
self._line_edit = QLineEdit(self)
self._line_edit.setPlaceholderText("Search registry...")
@@ -101,7 +100,7 @@ def __init__(self, parent):
"""Initialize class"""
super().__init__(parent)
self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False)
- self.setWindowTitle('Manage plugins')
+ self.setWindowTitle("Manage plugins")
QVBoxLayout(self)
self._list_view = QListView(self)
self._model = _ManagePluginsModel(self)
diff --git a/spinetoolbox/widgets/project_item_drag.py b/spinetoolbox/widgets/project_item_drag.py
index e71a21084..247a6f904 100644
--- a/spinetoolbox/widgets/project_item_drag.py
+++ b/spinetoolbox/widgets/project_item_drag.py
@@ -1,5 +1,6 @@
######################################################################################################################
# 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)
@@ -9,15 +10,11 @@
# this program. If not, see .
######################################################################################################################
-"""
-Classes for custom QListView.
-"""
-
+"""Classes for custom QListView."""
from textwrap import fill
-from PySide6.QtCore import QModelIndex, Qt, Signal, Slot, QMimeData, QMargins
-from PySide6.QtGui import QDrag, QIcon, QPainter, QBrush, QColor, QFont, QIconEngine
-from PySide6.QtWidgets import QToolButton, QApplication, QToolBar, QWidgetAction, QStyle
-from ..helpers import CharIconEngine, make_icon_background
+from PySide6.QtCore import Qt, Signal, Slot, QMimeData
+from PySide6.QtGui import QDrag, QIcon, QPainter, QBrush, QColor, QIconEngine, QCursor
+from PySide6.QtWidgets import QToolButton, QApplication, QToolTip
class ProjectItemDragMixin:
@@ -27,9 +24,17 @@ class ProjectItemDragMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+ self._reset()
+
+ def _reset(self):
self.drag_start_pos = None
self.pixmap = None
self.mime_data = None
+ self.setCursor(Qt.OpenHandCursor)
+
+ def mousePressEvent(self, event):
+ super().mousePressEvent(event)
+ self.setCursor(Qt.ClosedHandCursor)
def mouseMoveEvent(self, event):
"""Start dragging action if needed"""
@@ -53,9 +58,11 @@ def mouseMoveEvent(self, event):
def mouseReleaseEvent(self, event):
"""Forget drag start position"""
super().mouseReleaseEvent(event)
- self.drag_start_pos = None
- self.pixmap = None
- self.mime_data = None
+ self._reset()
+
+ def enterEvent(self, event):
+ super().enterEvent(event)
+ self.setCursor(Qt.OpenHandCursor)
class NiceButton(QToolButton):
@@ -64,7 +71,6 @@ def __init__(self, *args, **kwargs):
font = self.font()
font.setPointSize(9)
self.setFont(font)
- self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon)
def setText(self, text):
super().setText(fill(text, width=12, break_long_words=False))
@@ -72,8 +78,10 @@ def setText(self, text):
def set_orientation(self, orientation):
if orientation == Qt.Orientation.Horizontal:
self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon)
+ self.setStyleSheet("QToolButton{margin: 16px 2px 2px 2px;}")
else:
self.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
+ self.setStyleSheet("QToolButton{margin: 2px;}")
class ProjectItemButtonBase(ProjectItemDragMixin, NiceButton):
@@ -85,7 +93,11 @@ def __init__(self, toolbox, item_type, icon, parent=None):
self.setIcon(icon)
self.setMouseTracking(True)
self.drag_about_to_start.connect(self._handle_drag_about_to_start)
- self.setStyleSheet("QToolButton{padding: 2px}")
+ self.clicked.connect(self._show_tool_tip)
+
+ @Slot(bool)
+ def _show_tool_tip(self, _=False):
+ QToolTip.showText(QCursor.pos(), self.toolTip())
def set_colored_icons(self, colored):
self._icon.set_colored(colored)
@@ -194,298 +206,3 @@ def update(self):
def pixmap(self, size, mode, state):
return self._pixmap
-
-
-class ProjectItemSpecArray(QToolBar):
- """An array of ProjectItemSpecButton that can be expanded/collapsed."""
-
- def __init__(self, toolbox, model, item_type, icon):
- """
- Args:
- toolbox (ToolboxUI)
- model (FilteredSpecificationModel)
- item_type (str)
- icon (ColoredIcon)
- """
- super().__init__()
- self._extension_button = next(iter(self.findChildren(QToolButton)))
- self._margins = QMargins(4, 4, 4, 4)
- self.layout().setContentsMargins(self._margins)
- self._maximum_size = self.maximumSize()
- self._model = model
- self._toolbox = toolbox
- self.item_type = item_type
- self._icon = icon
- self._visible = False
- self._button_base_item = ProjectItemButton(self._toolbox, self.item_type, self._icon)
- self._button_base_item.double_clicked.connect(self.toggle_visibility)
- self.addWidget(self._button_base_item)
- self._button_visible = QToolButton()
- font = QFont("Font Awesome 5 Free Solid")
- font.setPointSize(8)
- self._button_visible.setFont(font)
- self._button_visible.setToolTip(f"