Skip to content

Commit

Permalink
Parameter type validation (#2912)
Browse files Browse the repository at this point in the history
  • Loading branch information
soininen authored Aug 7, 2024
2 parents 1b51b6a + edad040 commit 5cb26c0
Show file tree
Hide file tree
Showing 28 changed files with 1,358 additions and 278 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
- Entity group column in *Add entities* dialog. If filled, the created entity will be added to the specified group.
If the group doesn't yet exist, it will be created.
- Native kernel (i.e. python3 for Python) can now be used in the Detached Console or in Tool execution.
- It is now possible to specify valid value types for parameters in the Parameter definition table in Database editor.
- [Bundled App] **Embedded Python** now includes pip.
- Graph view in database editor now also supports filtering by alternative and scenario tree selections.
- Option to disable auto-build in entity graph.
Expand Down
12 changes: 10 additions & 2 deletions docs/source/spine_db_editor/adding_data.rst
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,13 @@ Only two of the fields are required when creating a new parameter definition: *e
*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.
specify valid value types, a parameter value list, a default value and a description.

The *valid types* column defines value types that are valid for the parameter.
An empty field means that all types are valid.
All values are validated against this column and non-valid types are marked invalid
in the *default_value* and *value* (in Parameter value table) columns.
Valid types are not enforced, however, so it is still possible to commit values of invalid type to the database.

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
Expand All @@ -182,7 +188,9 @@ see :ref:`parameter_value_list`.
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.
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.

Expand Down
5 changes: 5 additions & 0 deletions spinetoolbox/mvcmodels/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,8 @@

PARSED_ROLE = Qt.ItemDataRole.UserRole
DB_MAP_ROLE = Qt.ItemDataRole.UserRole + 1
PARAMETER_TYPE_VALIDATION_ROLE = Qt.ItemDataRole.UserRole + 2

INVALID_TYPE = 0
TYPE_NOT_VALIDATED = 1
VALID_TYPE = 2
144 changes: 144 additions & 0 deletions spinetoolbox/parameter_type_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
######################################################################################################################
# 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 <http://www.gnu.org/licenses/>.
######################################################################################################################
"""Contains utilities for validating parameter types."""
from dataclasses import dataclass
from multiprocessing import Pipe, Process
from typing import Any, Iterable, Optional, Tuple
from PySide6.QtCore import QObject, QTimer, Signal, Slot
from spinedb_api.db_mapping_helpers import is_parameter_type_valid, type_check_args

CHUNK_SIZE = 20


@dataclass(frozen=True)
class ValidationKey:
item_type: str
db_map_id: int
item_private_id: int


@dataclass(frozen=True)
class ValidatableValue:
key: ValidationKey
args: Tuple[Iterable[str], Optional[bytes], Optional[Any], Optional[str]]


class ParameterTypeValidator(QObject):
"""Handles parameter type validation in a concurrent process."""

validated = Signal(ValidationKey, bool)

def __init__(self, parent=None):
"""
Args:
parent (QObject, optional): parent object
"""
super().__init__(parent)
self._connection, scheduler_connection = Pipe()
self._process = Process(target=schedule, name="Type validation worker", args=(scheduler_connection,))
self._timer = QTimer(self)
self._timer.setInterval(100)
self._timer.timeout.connect(self._communicate)
self._task_queue = []
self._sent_task_count = 0

def set_interval(self, interval):
"""Sets the interval between communication attempts with the validation process.
Args:
interval (int): interval in milliseconds
"""
self._timer.setInterval(interval)

def start_validating(self, db_mngr, db_map, value_item_ids):
"""Initiates validation of given parameter definition/value items.
Args:
db_mngr (SpineDBManager): database manager
db_map (DatabaseMapping): database mapping
value_item_ids (Iterable of TempId): item ids to validate
"""
if not self._process.is_alive():
self._process.start()
for item_id in value_item_ids:
item = db_mngr.get_item(db_map, item_id.item_type, item_id)
args = type_check_args(item)
self._task_queue.append(
ValidatableValue(ValidationKey(item_id.item_type, id(db_map), item_id.private_id), args)
)
self._sent_task_count += 1
if not self._timer.isActive():
chunk = self._task_queue[:CHUNK_SIZE]
self._task_queue = self._task_queue[CHUNK_SIZE:]
self._connection.send(chunk)
self._timer.start()

@Slot()
def _communicate(self):
"""Communicates with the validation process."""
self._timer.stop()
if self._connection.poll():
results = self._connection.recv()
for key, result in results.items():
self.validated.emit(key, result)
self._sent_task_count -= len(results)
if self._task_queue and self._sent_task_count < 3 * CHUNK_SIZE:
chunk = self._task_queue[:CHUNK_SIZE]
self._task_queue = self._task_queue[CHUNK_SIZE:]
self._connection.send(chunk)
if not self._task_queue and self._sent_task_count == 0:
return
self._timer.start()

def tear_down(self):
"""Cleans up the validation process."""
self._timer.stop()
if self._process.is_alive():
self._connection.send("quit")
self._process.join()


def validate_chunk(validatable_values):
"""Validates given parameter definitions/values.
Args:
validatable_values (Iterable of ValidatableValue): values to validate
Returns:
dict: mapping from ValidationKey to boolean
"""
results = {}
for validatable_value in validatable_values:
results[validatable_value.key] = is_parameter_type_valid(*validatable_value.args)
return results


def schedule(connection):
"""Loops over incoming messages and sends responses back.
Args:
connection (Connection): A duplex Pipe end
"""
validatable_values = []
while True:
if connection.poll() or not validatable_values:
while True:
task = connection.recv()
if task == "quit":
return
validatable_values += task
if not connection.poll():
break
chunk = validatable_values[:CHUNK_SIZE]
validatable_values = validatable_values[CHUNK_SIZE:]
results = validate_chunk(chunk)
connection.send(results)
10 changes: 5 additions & 5 deletions spinetoolbox/spine_db_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def __init__(self, db_mngr, db_map, **kwargs):
"""
Args:
db_mngr (SpineDBManager): SpineDBManager instance
db_map (DiffDatabaseMapping): DiffDatabaseMapping instance
db_map (DatabaseMapping): DatabaseMapping instance
"""
super().__init__(**kwargs)
self.db_mngr = db_mngr
Expand All @@ -96,7 +96,7 @@ def __init__(self, db_mngr, db_map, item_type, data, check=True, **kwargs):
"""
Args:
db_mngr (SpineDBManager): SpineDBManager instance
db_map (DiffDatabaseMapping): DiffDatabaseMapping instance
db_map (DatabaseMapping): DatabaseMapping instance
data (list): list of dict-items to add
item_type (str): the item type
"""
Expand Down Expand Up @@ -130,7 +130,7 @@ def __init__(self, db_mngr, db_map, item_type, data, check=True, **kwargs):
"""
Args:
db_mngr (SpineDBManager): SpineDBManager instance
db_map (DiffDatabaseMapping): DiffDatabaseMapping instance
db_map (DatabaseMapping): DatabaseMapping instance
item_type (str): the item type
data (list): list of dict-items to update
"""
Expand Down Expand Up @@ -166,7 +166,7 @@ def __init__(self, db_mngr, db_map, item_type, data, check=True, **kwargs):
"""
Args:
db_mngr (SpineDBManager): SpineDBManager instance
db_map (DiffDatabaseMapping): DiffDatabaseMapping instance
db_map (DatabaseMapping): DatabaseMapping instance
item_type (str): the item type
data (list): list of dict-items to add-update
"""
Expand Down Expand Up @@ -215,7 +215,7 @@ def __init__(self, db_mngr, db_map, item_type, ids, check=True, **kwargs):
"""
Args:
db_mngr (SpineDBManager): SpineDBManager instance
db_map (DiffDatabaseMapping): DiffDatabaseMapping instance
db_map (DatabaseMapping): DatabaseMapping instance
item_type (str): the item type
ids (set): set of ids to remove
"""
Expand Down
73 changes: 53 additions & 20 deletions spinetoolbox/spine_db_editor/mvcmodels/compound_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
######################################################################################################################

""" Compound models. These models concatenate several 'single' models and one 'empty' model. """
from typing import ClassVar
from PySide6.QtCore import QModelIndex, Qt, QTimer, Slot
from PySide6.QtGui import QFont
from spinedb_api.parameter_value import join_value_and_type
Expand All @@ -25,6 +26,8 @@
class CompoundModelBase(CompoundWithEmptyTableModel):
"""A base model for all models that show data in stacked format."""

item_type: ClassVar[str] = NotImplemented

def __init__(self, parent, db_mngr, *db_maps):
"""
Args:
Expand Down Expand Up @@ -65,15 +68,6 @@ def column_filters(self):
def field_map(self):
return {}

@property
def item_type(self):
"""Returns the DB item type, e.g., 'parameter_value'.
Returns:
str
"""
raise NotImplementedError()

@property
def _single_model_type(self):
"""
Expand Down Expand Up @@ -318,7 +312,7 @@ def _items_per_class(items):
def handle_items_added(self, db_map_data):
"""Runs when either parameter definitions or values are added to the dbs.
Adds necessary sub-models and initializes them with data.
Also notifies the empty model so it can remove rows that are already in.
Also notifies the empty model, so it can remove rows that are already in.
Args:
db_map_data (dict): list of added dict-items keyed by DatabaseMapping
Expand Down Expand Up @@ -493,6 +487,19 @@ def _create_single_model(self, db_map, entity_class_id, committed):
class EditParameterValueMixin:
"""Provides the interface to edit values via ParameterValueEditor."""

def handle_items_updated(self, db_map_data):
super().handle_items_updated(db_map_data)
for db_map, items in db_map_data.items():
if db_map not in self.db_maps:
continue
items_by_class = self._items_per_class(items)
for entity_class_id, class_items in items_by_class.items():
single_model = next(
(m for m in self.single_models if (m.db_map, m.entity_class_id) == (db_map, entity_class_id)), None
)
if single_model is not None:
single_model.revalidate_item_types(class_items)

def index_name(self, index):
"""Generates a name for data at given index.
Expand Down Expand Up @@ -544,14 +551,13 @@ def get_set_data_delayed(self, index):
class CompoundParameterDefinitionModel(EditParameterValueMixin, CompoundModelBase):
"""A model that concatenates several single parameter_definition models and one empty parameter_definition model."""

@property
def item_type(self):
return "parameter_definition"
item_type = "parameter_definition"

def _make_header(self):
return [
"entity_class_name",
"parameter_name",
"valid types",
"value_list_name",
"default_value",
"description",
Expand All @@ -560,7 +566,11 @@ def _make_header(self):

@property
def field_map(self):
return {"parameter_name": "name", "value_list_name": "parameter_value_list_name"}
return {
"parameter_name": "name",
"valid types": "parameter_type_list",
"value_list_name": "parameter_value_list_name",
}

@property
def _single_model_type(self):
Expand All @@ -574,9 +584,16 @@ def _empty_model_type(self):
class CompoundParameterValueModel(FilterEntityAlternativeMixin, EditParameterValueMixin, CompoundModelBase):
"""A model that concatenates several single parameter_value models and one empty parameter_value model."""

@property
def item_type(self):
return "parameter_value"
item_type = "parameter_value"

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._definition_fetch_parent = FlexibleFetchParent(
"parameter_definition",
shows_item=lambda item, db_map: True,
handle_items_updated=self._handle_parameter_definitions_updated,
owner=self,
)

def _make_header(self):
return [
Expand All @@ -600,11 +617,27 @@ def _single_model_type(self):
def _empty_model_type(self):
return EmptyParameterValueModel

def reset_db_map(self, db_maps):
super().reset_db_maps(db_maps)
self._definition_fetch_parent.set_obsolete(False)
self._definition_fetch_parent.reset()

def _handle_parameter_definitions_updated(self, db_map_data):
for db_map, items in db_map_data.items():
if db_map not in self.db_maps:
continue
items_by_class = self._items_per_class(items)
for entity_class_id, class_items in items_by_class.items():
single_model = next(
(m for m in self.single_models if (m.db_map, m.entity_class_id) == (db_map, entity_class_id)), None
)
if single_model is not None:
single_model.revalidate_item_typs(class_items)


class CompoundEntityAlternativeModel(FilterEntityAlternativeMixin, CompoundModelBase):
@property
def item_type(self):
return "entity_alternative"

item_type = "entity_alternative"

def _make_header(self):
return [
Expand Down
Loading

0 comments on commit 5cb26c0

Please sign in to comment.