From afde89bca996291c7d1e7cbd494ad5c583d7422d Mon Sep 17 00:00:00 2001 From: Tab Memmott Date: Mon, 28 Jun 2021 12:13:17 -0700 Subject: [PATCH 1/3] Add anonymize field --- .bcipy/README.md | 36 ++++++ .gitignore | 3 +- README.md | 8 +- bcipy/gui/experiments/ExperimentField.py | 34 +++--- bcipy/gui/experiments/ExperimentRegistry.py | 84 ++++++++++---- bcipy/gui/experiments/FieldRegistry.py | 21 ++-- bcipy/gui/gui_main.py | 8 +- ...ing_buffer_test.py => test_ring_buffer.py} | 0 bcipy/helpers/exceptions.py | 47 ++++---- bcipy/helpers/load.py | 4 +- bcipy/helpers/save.py | 10 +- bcipy/helpers/tests/test_load.py | 3 +- bcipy/helpers/tests/test_validate.py | 105 ++++++++++++++++-- bcipy/helpers/validate.py | 58 ++++++++-- 14 files changed, 325 insertions(+), 96 deletions(-) create mode 100644 .bcipy/README.md rename bcipy/gui/tests/viewer/{ring_buffer_test.py => test_ring_buffer.py} (100%) diff --git a/.bcipy/README.md b/.bcipy/README.md new file mode 100644 index 000000000..e1349f633 --- /dev/null +++ b/.bcipy/README.md @@ -0,0 +1,36 @@ +# Experiments and Fields + +BciPy experiments and fields allow users to collect metadata that may be integrated alongside core BciPy data. This is particularly useful when the experiment has completed and a researcher wants to curate their files for sharing with the community. + +## Experiments + +An experiment defines the name, summary and fields to collect. At this level, the requirement for collection during every task and whether or not to anonymize later will be set. The anonymization does not encrypt the data at rest, but instead defines how to handle the field later when uploading/sharing. The registered experiments are defined in `.bcipy/experiment/experiments.json` in the following format: + +```js +{ + name: { + fields : { + name: "", + required: bool, + anonymize: bool + }, + summary: "" + } +} +``` + +## Fields + +A field is a unit of data collection for an experiment. It has a name, help text and type. The type will determine how it is collected and validated. The registered fields are defined in `.bcipy/field/fields.json` in the following format: + + +```js +{ + name: { + help_text: "", + type: "FieldType" + } +} +``` + +where FieldType may be str, bool, int, or float. \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0db163cba..1627098bf 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,5 @@ htmlcov/ bcipy.egg-info/ build/ dist/ -.bcipy/* -bcipy/parameters/parameters_* +bcipy/parameters/parameters_* \ No newline at end of file diff --git a/README.md b/README.md index a348737b9..fcba3e9a5 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Memmott, T., Koçanaoğulları, A., Lawhead, M., Klee, D., Dudy, S., Fried-Oken, ## Dependencies --------------- -This project requires Psychopy, Python v 3.6.5, and other packages. See requirements.txt. When possible integration with other open source libraries will be done. +This project requires Python > 3.6.5 and other packages defined in the requirements.txt. ## Installation @@ -42,7 +42,7 @@ This project requires Psychopy, Python v 3.6.5, and other packages. See requirem #### BCI Setup -In order to run BCI suite on your computer, first install **Python 3.6.5** [from here.](https://www.python.org/downloads/) +In order to run BCI suite on your computer, first install **Python 3** [from here.](https://www.python.org/downloads/) You must install Docker and Docker-Machine to use the Language Model developed by CSLU. There are instructions in the language model directory for getting the image you need (think of it as a callable server). You'll also need to download and load the language model [images](https://drive.google.com/drive/folders/1OYpUYASAceb60b2c5obyYytEZ0AZrajY?usp=sharing). If not using or rolling your own, set fake_lm to true in the parameters.json file. @@ -133,7 +133,7 @@ Use this resource for examples: http://docs.python-guide.org/en/latest/writing/s ## Testing ---------- -When writing tests, put them in the correct module, in a tests folder, and prefix the file and test itself with `test` in order for pytest to discover it. See other module tests for examples! +When writing tests, put them in the correct module, in a tests folder, and prefix the file and test itself with `test_` in order for pytest to discover it. See other module tests for examples! Development requirements must be installed before running: `pip install dev_requirements.txt` @@ -153,7 +153,7 @@ py.test acquisition To generate test coverage metrics, in the command line: ```bash -coverage run --branch --source=bcipy -m pytest +coverage run --branch --source=bcipy -m pytest --mpl -k "not slow" #Generate a command line report coverage report diff --git a/bcipy/gui/experiments/ExperimentField.py b/bcipy/gui/experiments/ExperimentField.py index 147679cab..47edabedb 100644 --- a/bcipy/gui/experiments/ExperimentField.py +++ b/bcipy/gui/experiments/ExperimentField.py @@ -8,7 +8,6 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QHBoxLayout, - QMessageBox, QPushButton, QScrollArea, QVBoxLayout, @@ -17,7 +16,9 @@ from bcipy.gui.gui_main import ( AlertMessageType, + AlertMessageResponse, AlertResponse, + MessageBox, app, BoolInput, DirectoryInput, @@ -47,6 +48,7 @@ class ExperimentFieldCollection(QWidget): 'directorypath': DirectoryInput } require_mark = '*' + alert_timeout = 10 save_data = {} def __init__(self, title: str, width: int, height: int, experiment_name: str, save_path: str, file_name: str): @@ -95,6 +97,7 @@ def field_input(self, field_name: str, field_type: str, help_tip: str, required: """ form_input = self.type_inputs.get(field_type, TextInput) + if required: field_name += self.require_mark return form_input( @@ -107,7 +110,7 @@ def field_input(self, field_name: str, field_type: str, help_tip: str, required: def build_assets(self) -> None: """Build Assets. - Build any needed assests for the Experiment Field Widget. Currently, only a form is needed. + Build any needed assets for the Experiment Field Widget. Currently, only a form is needed. """ self.build_form() @@ -124,7 +127,8 @@ def check_input(self) -> bool: title='BciPy Alert', message=f'Required field {name.strip(self.require_mark)} must be filled out!', message_type=AlertMessageType.CRIT, - okay_or_cancel=True) + message_response=AlertMessageResponse.OCE, + message_timeout=self.alert_timeout) return False return True @@ -171,7 +175,8 @@ def build_save_data(self) -> None: title='Error', message=f'Error saving data. Invalid value provided. \n {e}', message_type=AlertMessageType.WARN, - okay_or_cancel=True + message_response=AlertMessageResponse.OCE, + message_timeout=self.alert_timeout ) def write_save_data(self) -> None: @@ -179,28 +184,30 @@ def write_save_data(self) -> None: self.throw_alert_message( title='Success', message=( - f'Data sucessfully written to: \n\n{self.save_path}/{self.file_name} \n\n\n' - 'Please close this window to start the task!'), + f'Data successfully written to: \n\n{self.save_path}/{self.file_name} \n\n\n' + 'Please wait or close this window to start the task!'), message_type=AlertMessageType.INFO, - okay_or_cancel=True + message_response=AlertMessageResponse.OCE, + message_timeout=self.alert_timeout, ) def throw_alert_message(self, title: str, message: str, message_type: AlertMessageType = AlertMessageType.INFO, - okay_to_exit: bool = False, - okay_or_cancel: bool = False) -> QMessageBox: + message_response: AlertMessageResponse = AlertMessageResponse.OTE, + message_timeout: float = 0) -> MessageBox: """Throw Alert Message.""" - msg = QMessageBox() + msg = MessageBox() msg.setWindowTitle(title) msg.setText(message) msg.setIcon(message_type.value) + msg.setTimeout(message_timeout) - if okay_to_exit: + if message_response is AlertMessageResponse.OTE: msg.setStandardButtons(AlertResponse.OK.value) - elif okay_or_cancel: + elif message_response is AlertMessageResponse.OCE: msg.setStandardButtons(AlertResponse.OK.value | AlertResponse.CANCEL.value) return msg.exec_() @@ -267,7 +274,6 @@ def start_app() -> None: from bcipy.helpers.system_utils import DEFAULT_EXPERIMENT_ID parser = argparse.ArgumentParser() - # save path # experiment_name parser.add_argument('-p', '--path', default='.', @@ -292,7 +298,7 @@ def start_app() -> None: file_name = args.filename bcipy_gui = app(sys.argv) - # todo change height based on field #? + ex = MainPanel( title='Experiment Field Collection', height=250, diff --git a/bcipy/gui/experiments/ExperimentRegistry.py b/bcipy/gui/experiments/ExperimentRegistry.py index 35db1471b..93e6dfd81 100644 --- a/bcipy/gui/experiments/ExperimentRegistry.py +++ b/bcipy/gui/experiments/ExperimentRegistry.py @@ -1,7 +1,7 @@ import sys import subprocess -from bcipy.gui.gui_main import BCIGui, app, AlertMessageType, ScrollableFrame, LineItems +from bcipy.gui.gui_main import BCIGui, app, AlertMessageType, AlertMessageResponse, ScrollableFrame, LineItems from bcipy.helpers.load import load_experiments, load_fields from bcipy.helpers.save import save_experiment_data @@ -18,13 +18,14 @@ class ExperimentRegistry(BCIGui): btn_height = 50 default_text = '...' alert_title = 'Experiment Registry Alert' + alert_timeout = 10 experiment_fields = [] def __init__(self, *args, **kwargs): super(ExperimentRegistry, self).__init__(*args, **kwargs) # Structure of an experiment: - # { name: { fields : {name: '', required: bool}, summary: '' } } + # { name: { fields : {name: '', required: bool, anonymize: bool}, summary: '' } } self.update_experiment_data() # These are set in the build_inputs and represent text inputs from the user @@ -34,7 +35,9 @@ def __init__(self, *args, **kwargs): self.panel = None self.line_items = None + # fields is for display of registered fields self.fields = [] + self.registered_fields = load_fields() self.name = None self.summary = None @@ -79,6 +82,24 @@ def toggle_required_field(self) -> None: field[field_name]['required'] = 'false' self.refresh_field_panel() + def toggle_anonymize_field(self) -> None: + """Toggle Anonymize Field. + + *Button Action* + + Using the field_name retrieved from the button (get_id), find the field in self.experiment_fields and toggle + the anonymize field ('true' or 'false'). + """ + field_name = self.window.sender().get_id() + for field in self.experiment_fields: + if field_name in field: + anonymize = field[field_name]['anonymize'] + if anonymize == 'false': + field[field_name]['anonymize'] = 'true' + else: + field[field_name]['anonymize'] = 'false' + self.refresh_field_panel() + def remove_field(self) -> None: """Remove Field. @@ -104,23 +125,33 @@ def build_line_items_from_fields(self) -> None: """Build Line Items From Fields. Loop over the registered experiment fields and create LineItems, which can by used to toggle the required - field or remove as a registered experiment field. + field, anonymization, or remove as a registered experiment field. """ items = [] for field in self.experiment_fields: - # experiment fields is a list of dicts, here we loop over the dict to get the field_name and requirement + # experiment fields is a list of dicts, here we loop over the dict to get + # the field_name, anonymization, and requirement for field_name, required in field.items(): - # Set the button text and colors, based on the requirement + # Set the button text and colors, based on the requirement and anonymization if required['required'] == 'false': - required_button_label = 'Not Required' + required_button_label = 'Do Not Require' required_button_color = 'black' required_button_text_color = 'white' else: - required_button_label = 'Required' + required_button_label = 'Require' required_button_color = 'green' required_button_text_color = 'white' + if required['anonymize'] == 'false': + anon_button_label = 'Do Not Anonymize' + anon_button_color = 'black' + anon_button_text_color = 'white' + else: + anon_button_label = 'Anonymize' + anon_button_color = 'green' + anon_button_text_color = 'white' + # Construct the item to turn into a LineItem, we set the id as field_name to use later via the action item = { field_name: { @@ -130,6 +161,12 @@ def build_line_items_from_fields(self) -> None: 'textColor': required_button_text_color, 'id': field_name }, + anon_button_label: { + 'action': self.toggle_anonymize_field, + 'color': anon_button_color, + 'textColor': anon_button_text_color, + 'id': field_name + }, 'Remove': { 'action': self.remove_field, 'color': 'red', @@ -253,8 +290,6 @@ def build_buttons(self): text_color='white' ) - # remove field - # add field self.add_button( message='Register', @@ -277,7 +312,8 @@ def create_experiment(self) -> None: title=self.alert_title, message='Experiment saved successfully! Please exit window or create another experiment!', message_type=AlertMessageType.INFO, - okay_to_exit=True + message_response=AlertMessageResponse.OTE, + message_timeout=self.alert_timeout ) self.update_experiment_data() @@ -293,7 +329,7 @@ def add_experiment(self) -> None: """Add Experiment: Add a new experiment to the dict of experiments. It follows the format: - { name: { fields : {name: '', required: bool}, summary: '' } } + { name: { fields : {name: '', required: bool, anonymize: bool}, summary: '' } } """ self.experiments[self.name] = { 'fields': self.experiment_fields, @@ -306,7 +342,7 @@ def save_experiments(self) -> None: Save the experiments registered to the correct path as pulled from system_utils. """ # add fields to the experiment - save_experiment_data(self.experiments, DEFAULT_EXPERIMENT_PATH, EXPERIMENT_FILENAME) + save_experiment_data(self.experiments, self.registered_fields, DEFAULT_EXPERIMENT_PATH, EXPERIMENT_FILENAME) def create_field(self) -> None: """Create Field. @@ -332,9 +368,10 @@ def add_field(self) -> None: if field in registered_fields: return self.throw_alert_message( title=self.alert_title, - message='Field already registered in the experiment!', + message=f'{field} already registered with this experiment!', message_type=AlertMessageType.INFO, - okay_to_exit=True + message_response=AlertMessageResponse.OTE, + message_timeout=self.alert_timeout, ) # else add the field! @@ -342,9 +379,9 @@ def add_field(self) -> None: { field: { - 'required': 'false' + 'required': 'false', + 'anonymize': 'true' } - } ) @@ -355,7 +392,8 @@ def update_field_list(self) -> None: self.field_input.clear() self.field_input.addItem(ExperimentRegistry.default_text) - self.fields = [item for item in load_fields()] + self.registered_fields = load_fields() + self.fields = [item for item in self.registered_fields] self.field_input.addItems(self.fields) def build_assets(self) -> None: @@ -380,7 +418,8 @@ def check_input(self) -> bool: title=self.alert_title, message='Please add an Experiment Name!', message_type=AlertMessageType.WARN, - okay_to_exit=True) + message_response=AlertMessageResponse.OTE, + message_timeout=self.alert_timeout) return False if self.name in self.experiment_names: self.throw_alert_message( @@ -391,21 +430,24 @@ def check_input(self) -> bool: f'Registed names: {self.experiment_names}' ), message_type=AlertMessageType.WARN, - okay_to_exit=True) + message_response=AlertMessageResponse.OTE, + message_timeout=self.alert_timeout) return False if self.summary == ExperimentRegistry.default_text: self.throw_alert_message( title=self.alert_title, message='Please add an Experiment Summary!', message_type=AlertMessageType.WARN, - okay_to_exit=True) + message_response=AlertMessageResponse.OTE, + message_timeout=self.alert_timeout) return False except Exception as e: self.throw_alert_message( title=self.alert_title, message=f'Error, {e}', message_type=AlertMessageType.CRIT, - okay_to_exit=True) + message_response=AlertMessageResponse.OTE, + message_timeout=self.alert_timeout) return False return True diff --git a/bcipy/gui/experiments/FieldRegistry.py b/bcipy/gui/experiments/FieldRegistry.py index a37c4e9fe..f63a084f4 100644 --- a/bcipy/gui/experiments/FieldRegistry.py +++ b/bcipy/gui/experiments/FieldRegistry.py @@ -1,5 +1,5 @@ import sys -from bcipy.gui.gui_main import BCIGui, app, AlertMessageType +from bcipy.gui.gui_main import BCIGui, app, AlertMessageType, AlertMessageResponse from bcipy.helpers.load import load_fields from bcipy.helpers.save import save_field_data @@ -19,6 +19,7 @@ class FieldRegistry(BCIGui): input_text_color = 'gray' input_color = 'white' alert_title = 'Field Registry Alert' + alert_timeout = 10 field_types = { 'Text': 'str', 'True/False or Yes/No': 'bool', @@ -146,7 +147,8 @@ def create_field(self) -> None: title=self.alert_title, message='Field saved successfully! Please exit window or create another field!', message_type=AlertMessageType.INFO, - okay_to_exit=True + message_response=AlertMessageResponse.OTE, + message_timeout=self.alert_timeout ) self.refresh_inputs() @@ -223,7 +225,8 @@ def check_input(self) -> bool: title=self.alert_title, message='Please add a field name!', message_type=AlertMessageType.WARN, - okay_to_exit=True) + message_response=AlertMessageResponse.OTE, + message_timeout=self.alert_timeout) return False if self.name in self.field_names: self.throw_alert_message( @@ -234,7 +237,8 @@ def check_input(self) -> bool: f'Registed names: {self.field_names}' ), message_type=AlertMessageType.WARN, - okay_to_exit=True) + message_response=AlertMessageResponse.OTE, + message_timeout=self.alert_timeout) return False if self.helptext == FieldRegistry.default_text or \ self.helptext == '': @@ -242,21 +246,24 @@ def check_input(self) -> bool: title=self.alert_title, message='Please add help text to field. \nThis will present when collecting experiment data!', message_type=AlertMessageType.WARN, - okay_to_exit=True) + message_response=AlertMessageResponse.OTE, + message_timeout=self.alert_timeout) return False if self.type == FieldRegistry.default_text: self.throw_alert_message( title=self.alert_title, message='Please select a valid field type!', message_type=AlertMessageType.WARN, - okay_to_exit=True) + message_response=AlertMessageResponse.OTE, + message_timeout=self.alert_timeout) return False except Exception as e: self.throw_alert_message( title=self.alert_title, message=f'Error, {e}', message_type=AlertMessageType.CRIT, - okay_to_exit=True) + message_response=AlertMessageResponse.OTE, + message_timeout=self.alert_timeout) return False return True diff --git a/bcipy/gui/gui_main.py b/bcipy/gui/gui_main.py index e5286109b..9d91931e0 100644 --- a/bcipy/gui/gui_main.py +++ b/bcipy/gui/gui_main.py @@ -202,7 +202,7 @@ def __init__(self, self.init_layout() def init_label(self) -> QWidget: - """Initize the label widget.""" + """Initialize the label widget.""" return static_text_control(None, label=self.label) def init_help(self, font_size: int, color: str) -> QWidget: @@ -244,7 +244,7 @@ def cast_value(self) -> Any: *If not defined by downstream classes, it will return the value.* """ - self.value() + return self.value() def matches(self, term: str) -> bool: """Returns True if the input matches the given text, otherwise False.""" @@ -287,7 +287,9 @@ def init_control(self, value): """Override FormInput to create a spinbox.""" spin_box = QSpinBox() spin_box.setMaximum(100000) - spin_box.setValue(int(value)) + + if value: + spin_box.setValue(int(value)) return spin_box def cast_value(self) -> str: diff --git a/bcipy/gui/tests/viewer/ring_buffer_test.py b/bcipy/gui/tests/viewer/test_ring_buffer.py similarity index 100% rename from bcipy/gui/tests/viewer/ring_buffer_test.py rename to bcipy/gui/tests/viewer/test_ring_buffer.py diff --git a/bcipy/helpers/exceptions.py b/bcipy/helpers/exceptions.py index 14e0b4bfd..875a922c0 100644 --- a/bcipy/helpers/exceptions.py +++ b/bcipy/helpers/exceptions.py @@ -1,5 +1,4 @@ - class BciPyCoreException(Exception): """BciPy Core Exception. @@ -11,26 +10,29 @@ def __init__(self, message, errors=None): self.errors = errors -class UnregisteredExperimentException(Exception): - """Unregistered Experiment. +class FieldException(BciPyCoreException): + """Field Exception. - Thrown when experiment is not registered in the provided experiment path. + Thrown when there is an exception relating to experimental fields. """ + ... - def __init__(self, message, errors=None): - super().__init__(message) - self.errors = errors +class ExperimentException(BciPyCoreException): + """Experiment Exception. -class FieldException(Exception): - """Field Exception. + Thrown when there is an exception relating to experiments. + """ + ... - Thrown when there is an exception relating to experimental fields. + +class UnregisteredExperimentException(ExperimentException): + """Unregistered Experiment. + + Thrown when experiment is not registered in the provided experiment path. """ - def __init__(self, message, errors=None): - super().__init__(message) - self.errors = errors + ... class UnregisteredFieldException(FieldException): @@ -39,17 +41,22 @@ class UnregisteredFieldException(FieldException): Thrown when field is not registered in the provided field path. """ - def __init__(self, message, errors=None): - super().__init__(message) - self.errors = errors + ... -class InvalidExperimentException(Exception): +class InvalidExperimentException(ExperimentException): """Invalid Experiment Exception. Thrown when providing experiment data in the incorrect format. """ - def __init__(self, message, errors=None): - super().__init__(message) - self.errors = errors + ... + + +class InvalidFieldException(FieldException): + """Invalid Field Exception. + + Thrown when providing field data in the incorrect format. + """ + + ... diff --git a/bcipy/helpers/load.py b/bcipy/helpers/load.py index 6ed3d50aa..a64d819b2 100644 --- a/bcipy/helpers/load.py +++ b/bcipy/helpers/load.py @@ -57,7 +57,7 @@ def load_experiments(path: str = f'{DEFAULT_EXPERIMENT_PATH}{EXPERIMENT_FILENAME Returns ------- A dictionary of experiments, with the following format: - { name: { fields : {name: '', required: bool}, summary: '' } } + { name: { fields : {name: '', required: bool, anonymize: bool}, summary: '' } } """ with open(path, 'r') as json_file: @@ -70,7 +70,7 @@ def extract_mode(bcipy_data_directory: str) -> str: This method extracts the task mode from a BciPy data save directory. This is important for trigger conversions and extracting targeteness. - *note*: this is not compatiable with older versions of BciPy (pre 1.5.0) where + *note*: this is not compatible with older versions of BciPy (pre 1.5.0) where the tasks and modes were considered together using integers (1, 2, 3). PARAMETERS diff --git a/bcipy/helpers/save.py b/bcipy/helpers/save.py index faeca1dbb..6de9b0944 100644 --- a/bcipy/helpers/save.py +++ b/bcipy/helpers/save.py @@ -1,3 +1,4 @@ +from bcipy.helpers.validate import validate_experiments import errno import os from time import localtime, strftime @@ -24,12 +25,13 @@ def save_json_data(data: dict, location: str, name: str) -> str: return str(path) -def save_experiment_data(data, location, name) -> str: - return save_json_data(data, location, name) +def save_experiment_data(experiments, fields, location, name) -> str: + validate_experiments(experiments, fields) + return save_json_data(experiments, location, name) -def save_field_data(data, location, name) -> str: - return save_json_data(data, location, name) +def save_field_data(fields, location, name) -> str: + return save_json_data(fields, location, name) def save_experiment_field_data(data, location, name) -> str: diff --git a/bcipy/helpers/tests/test_load.py b/bcipy/helpers/tests/test_load.py index 92e56eeca..e684f155f 100644 --- a/bcipy/helpers/tests/test_load.py +++ b/bcipy/helpers/tests/test_load.py @@ -27,7 +27,8 @@ "fields": [ { "age": { - "required": "false" + "required": "false", + 'anonymize': 'true' } } ], diff --git a/bcipy/helpers/tests/test_validate.py b/bcipy/helpers/tests/test_validate.py index eff2ef5a2..a64eb10df 100644 --- a/bcipy/helpers/tests/test_validate.py +++ b/bcipy/helpers/tests/test_validate.py @@ -1,9 +1,10 @@ -import os import unittest -from bcipy.helpers.validate import validate_experiment +from bcipy.helpers.validate import validate_experiment, validate_experiments from bcipy.helpers.save import save_experiment_data from bcipy.helpers.exceptions import ( + InvalidExperimentException, + InvalidFieldException, UnregisteredExperimentException, UnregisteredFieldException, ) @@ -25,22 +26,23 @@ def test_validate_experiment_throws_unregistered_expection_on_unregistered_exper with self.assertRaises(UnregisteredExperimentException): validate_experiment(experiment_name) - def test_validate_experiment_throws_unregistered_exception_on_unregistered_fields(self): + def test_save_experiment_data_throws_unregistered_exception_on_unregistered_fields(self): # create a fake experiment to load experiment_name = 'test' + fields = { + 'registered_field': { + 'help_text': 'test', + 'type': 'int' + } + } experiment = { experiment_name: { - 'fields': [{'does_not_exist': {'required': 'false'}}], 'summary': ''} + 'fields': [{'does_not_exist': {'required': 'false', 'anonymize': 'true'}}], 'summary': ''} } - # save it to a custom path (away from default) - path = save_experiment_data(experiment, '.', 'test_experiment.json') - # assert it raises the expected exception with self.assertRaises(UnregisteredFieldException): - validate_experiment(experiment_name, experiment_path=path) - - os.remove(path) + save_experiment_data(experiment, fields, '.', 'test_experiment.json') def test_validate_experiment_throws_file_not_found_with_incorrect_experiment_path(self): # define an invalid path @@ -57,3 +59,86 @@ def test_validate_experiment_throws_file_not_found_with_incorrect_field_path(sel # assert it raises the expected exception for an invalid field_path kwarg with self.assertRaises(FileNotFoundError): validate_experiment(DEFAULT_EXPERIMENT_ID, field_path=path) + + +class TestValidateExperiments(unittest.TestCase): + experiment_name = 'test' + fields = { + 'registered_field': { + 'help_text': 'test', + 'type': 'int' + } + } + experiments = { + experiment_name: { + 'fields': [{'registered_field': {'required': 'false', 'anonymize': 'true'}}], 'summary': ''} + } + + def test_validate_experiments_returns_true_on_valid_experiment(self): + response = validate_experiments(self.experiments, self.fields) + + self.assertTrue(response) + + def test_validate_experiments_throws_invalid_experiment_exception_on_invalid_experiment_no_field(self): + experiments = { + 'invalid': { + 'summary': ''} + } + with self.assertRaises(InvalidExperimentException): + validate_experiments(experiments, self.fields) + + def test_validate_experiments_throws_invalid_experiment_exception_on_invalid_experiment_invalid_field(self): + experiments = { + 'invalid': { + 'summary': '', + 'fields': 'should_be_list!'} + } + with self.assertRaises(InvalidExperimentException): + validate_experiments(experiments, self.fields) + + def test_validate_experiments_throws_invalid_experiment_exception_on_invalid_experiment_invalid_summary(self): + experiments = { + 'invalid': { + 'summary': [], + 'fields': []} + } + with self.assertRaises(InvalidExperimentException): + validate_experiments(experiments, self.fields) + + def test_validate_experiments_throws_invalid_experiment_exception_on_invalid_experiment_no_summary(self): + experiments = { + 'invalid': { + 'fields': []} + } + with self.assertRaises(InvalidExperimentException): + validate_experiments(experiments, self.fields) + + def test_validate_experiments_throws_invalid_field_exception_on_invalid_field_no_required(self): + experiments = { + self.experiment_name: { + 'fields': [{'registered_field': {'anonymize': 'true'}}], 'summary': ''} + } + with self.assertRaises(InvalidFieldException): + validate_experiments(experiments, self.fields) + + def test_validate_experiments_throws_invalid_field_exception_on_invalid_field_no_anonymize(self): + experiments = { + self.experiment_name: { + 'fields': [{'registered_field': {'required': 'true'}}], 'summary': ''} + } + with self.assertRaises(InvalidFieldException): + validate_experiments(experiments, self.fields) + + def test_validate_experiments_throws_unregistered_exception_on_unregistered_fields(self): + experiment = { + self.experiment_name: { + 'fields': [{'does_not_exist': {'required': 'false', 'anonymize': 'true'}}], 'summary': ''} + } + + # assert it raises the expected exception + with self.assertRaises(UnregisteredFieldException): + validate_experiments(experiment, self.fields) + + +if __name__ == '__main__': + unittest.main() diff --git a/bcipy/helpers/validate.py b/bcipy/helpers/validate.py index 62e28ddba..3e446449e 100644 --- a/bcipy/helpers/validate.py +++ b/bcipy/helpers/validate.py @@ -1,9 +1,10 @@ import os -from bcipy.helpers.load import load_experiments, load_fields, load_experiment_fields +from bcipy.helpers.load import load_experiments, load_fields from bcipy.helpers.system_utils import DEFAULT_EXPERIMENT_PATH, DEFAULT_FIELD_PATH, EXPERIMENT_FILENAME, FIELD_FILENAME from bcipy.helpers.exceptions import ( - FieldException, + InvalidFieldException, + InvalidExperimentException, UnregisteredExperimentException, UnregisteredFieldException ) @@ -28,17 +29,30 @@ def validate_experiment( raise UnregisteredExperimentException( f'Experiment [{experiment_name}] is not registered at path [{experiment_path}]') - # grab all field names as a list of strings. This call will raise exceptions if formatted incorrectly. - experiment_fields = load_experiment_fields(experiment) + # attempt to load the experiment by name + _validate_experiment_format(experiment, experiment_name) + _validate_experiment_fields(experiment['fields'], fields) + + return True + +def _validate_experiment_fields(experiment_fields, fields): # loop over the experiment fields and attempt to load them by name for field in experiment_fields: + # field is a dictionary with one key another dictionary as its' value + # {'field_name': {require: bool, anonymize: bool}} + field_name = list(field.keys())[0] try: - fields[field] + fields[field_name] except KeyError: - raise UnregisteredFieldException(f'Field [{field}] is not registered at path [{field_path}]') + raise UnregisteredFieldException(f'Field [{field}] is not registered in [{fields}]') - return True + try: + field[field_name]['required'] + field[field_name]['anonymize'] + except KeyError: + raise InvalidFieldException( + f'Experiment Field [{field}] incorrectly formatted. It should contain: required and anonymize') def validate_field_data_written(path: str, file_name: str) -> bool: @@ -49,4 +63,32 @@ def validate_field_data_written(path: str, file_name: str) -> bool: experiment_data_path = f'{path}/{file_name}' if os.path.isfile(experiment_data_path): return True - raise FieldException(f'Experimental field data expected at path=[{experiment_data_path}] but not found.') + raise InvalidFieldException(f'Experimental field data expected at path=[{experiment_data_path}] but not found.') + + +def validate_experiments(experiments, fields) -> bool: + """Validate Experiments. + + Validate all experiments are in the correct format and the fields are properly registered. + """ + for experiment_name in experiments: + experiment = experiments[experiment_name] + + _validate_experiment_format(experiment, experiment_name) + _validate_experiment_fields(experiment['fields'], fields) + + return True + + +def _validate_experiment_format(experiment, name): + try: + exp_summary = experiment['summary'] + assert isinstance(exp_summary, str) + experiment_fields = experiment['fields'] + assert isinstance(experiment_fields, list) + except KeyError: + raise InvalidExperimentException( + f'Experiment [{name}] is formatted incorrectly. It should contain the keys: summary and fields.') + except AssertionError: + raise InvalidExperimentException( + f'Experiment [{name}] is formatted incorrectly. Unexpected type on summary(string) or fields(List[dict]).') From cbebff06c63541272c42c5d4e6c353afc0076fee Mon Sep 17 00:00:00 2001 From: Tab Memmott Date: Tue, 13 Jul 2021 17:57:35 -0700 Subject: [PATCH 2/3] Address PR feedback --- bcipy/gui/experiments/ExperimentRegistry.py | 8 ++++---- bcipy/helpers/tests/test_load.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bcipy/gui/experiments/ExperimentRegistry.py b/bcipy/gui/experiments/ExperimentRegistry.py index 93e6dfd81..31a8bae52 100644 --- a/bcipy/gui/experiments/ExperimentRegistry.py +++ b/bcipy/gui/experiments/ExperimentRegistry.py @@ -135,20 +135,20 @@ def build_line_items_from_fields(self) -> None: # Set the button text and colors, based on the requirement and anonymization if required['required'] == 'false': - required_button_label = 'Do Not Require' + required_button_label = 'Optional' required_button_color = 'black' required_button_text_color = 'white' else: - required_button_label = 'Require' + required_button_label = 'Required' required_button_color = 'green' required_button_text_color = 'white' if required['anonymize'] == 'false': - anon_button_label = 'Do Not Anonymize' + anon_button_label = 'Onymous' anon_button_color = 'black' anon_button_text_color = 'white' else: - anon_button_label = 'Anonymize' + anon_button_label = 'Anonymous' anon_button_color = 'green' anon_button_text_color = 'white' diff --git a/bcipy/helpers/tests/test_load.py b/bcipy/helpers/tests/test_load.py index e684f155f..15e939a9a 100644 --- a/bcipy/helpers/tests/test_load.py +++ b/bcipy/helpers/tests/test_load.py @@ -28,7 +28,7 @@ { "age": { "required": "false", - 'anonymize': 'true' + "anonymize": "true" } } ], From 0df6f625aba3171b280d28e9fa98aead11f7a1ee Mon Sep 17 00:00:00 2001 From: Tab Memmott Date: Tue, 13 Jul 2021 18:12:58 -0700 Subject: [PATCH 3/3] Update changelog --- CHANGELOG.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c98abcbf..ffa5557e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ -# 2.0.0 +# 2.0.0-rc.1 ## Contributions -This version contains major refactoring efforts and features. We anticipate a few additional refactor efforts in the near term based on feature requests from the community and CAMBI. These will support multi-modality, data sharing, and more complex language modeling. +This version contains major refactoring efforts and features. We anticipate a few additional refactor efforts in the near term based on feature requests from the community and CAMBI. These will support multi-modality, data sharing, and more complex language modeling. We are utilizing a release candidate to make features and bugfixes available sooner despite the full second version being in-progress. Thank you for your understanding and continued support! ### Added @@ -10,6 +10,8 @@ This version contains major refactoring efforts and features. We anticipate a fe - `bcipy.helpers.stimuli.StimuliOrder`: defined ordering of inquiry stimuli. The current approach is to randomize. This adds an alphabetical option. #153 - `bcipy.helpers.stimuli.alphabetize`: method for taking a list of strings and returning them in alphabetical order with characters last in the list. #153 +- `.bcipy/README.md`: describes experiments and fields in greater detail #156 +- `validate`: `_validate_experiment_fields` and `validate_experiments`: validates experiments and fields in the correct format #156 ### Updated @@ -20,6 +22,13 @@ This version contains major refactoring efforts and features. We anticipate a fe - `demo_stimuli_generation.py`: update imports and add a case showing the new ordering functionality. #153 - `copy_phrase_wrapper`: update logging and exception handling. add stim order. #153 - `random_rsvp_calibration_inq_gen`: rename to `calibration_inquiry_generator` #153 +- `ExperimentField.py`: updated to use new alert types with timeouts #156 +- `ExperimentRegistry.py`: add the ability to toggle anonymization of field data and use new alert types with timeouts #156 +- `FieldRegistry.py`: updated to use new alert types with timeout #156 +- `README.md`: update dependencies and coverage run command #156 +- `gui/gui_main.py`: update to return a value in FormInput, set a value for IntegerInput only if provided #156 +- `ring_buffer_test.py` -> `test_ring_buffer.py`: to comply with naming conventions #156 +- `exceptions`: refactored Field and Experiment exceptions to corresponding base exception #156 ### Removed