From adf4889578710a78a172be52278676a900b0b73a Mon Sep 17 00:00:00 2001 From: IanCa Date: Fri, 19 May 2023 12:27:59 -0500 Subject: [PATCH 1/2] Switch baseinput/column mapper back to 0 based. Clarify documentation of column_prefix_dictionary. --- hed/errors/error_messages.py | 5 +++ hed/errors/error_types.py | 1 + hed/models/column_mapper.py | 35 ++++++++-------- hed/models/spreadsheet_input.py | 16 ++++---- hed/models/tabular_input.py | 1 - tests/models/test_spreadsheet_input.py | 55 +++++++++++++++----------- 6 files changed, 65 insertions(+), 48 deletions(-) diff --git a/hed/errors/error_messages.py b/hed/errors/error_messages.py index 95a4f438b..c0091809a 100644 --- a/hed/errors/error_messages.py +++ b/hed/errors/error_messages.py @@ -127,6 +127,11 @@ def val_error_hed_duplicate_column(column_name): return f"Multiple columns have name {column_name}. This is not a fatal error, but discouraged." +@hed_error(ValidationErrors.DUPLICATE_NAME_NUMBER_COLUMN, default_severity=ErrorSeverity.WARNING) +def val_error_hed_duplicate_column_number(column_name, column_number): + return f"Column '{column_name}' added as a named column, then also as numbered column {column_number}" + + @hed_tag_error(ValidationErrors.HED_LIBRARY_UNMATCHED, actual_code=ValidationErrors.TAG_PREFIX_INVALID) def val_error_unknown_prefix(tag, unknown_prefix, known_prefixes): return f"Tag '{tag} has unknown prefix '{unknown_prefix}'. Valid prefixes: {known_prefixes}" diff --git a/hed/errors/error_types.py b/hed/errors/error_types.py index a7be7d2b4..cb37be803 100644 --- a/hed/errors/error_types.py +++ b/hed/errors/error_types.py @@ -81,6 +81,7 @@ class ValidationErrors: HED_MISSING_REQUIRED_COLUMN = "HED_MISSING_REQUIRED_COLUMN" HED_UNKNOWN_COLUMN = "HED_UNKNOWN_COLUMN" HED_DUPLICATE_COLUMN = "HED_DUPLICATE_COLUMN" + DUPLICATE_NAME_NUMBER_COLUMN = "DUPLICATE_NAME_NUMBER_COLUMN" HED_BLANK_COLUMN = "HED_BLANK_COLUMN" # Below here shows what the given error maps to diff --git a/hed/models/column_mapper.py b/hed/models/column_mapper.py index ad68114a8..37280683f 100644 --- a/hed/models/column_mapper.py +++ b/hed/models/column_mapper.py @@ -12,7 +12,7 @@ class ColumnMapper: """ Mapping of a base input file columns into HED tags. Notes: - - Functions and type_variables column and row indexing starts at 0. + - All column numbers are 0 based. """ def __init__(self, sidecar=None, tag_columns=None, column_prefix_dictionary=None, optional_tag_columns=None, requested_columns=None, warn_on_missing_column=False): @@ -22,10 +22,12 @@ def __init__(self, sidecar=None, tag_columns=None, column_prefix_dictionary=None sidecar (Sidecar): A sidecar to gather column data from. tag_columns: (list): A list of ints or strings containing the columns that contain the HED tags. Sidecar column definitions will take precedent if there is a conflict with tag_columns. - column_prefix_dictionary (dict): Dictionary with keys that are column numbers and values are HED tag + column_prefix_dictionary (dict): Dictionary with keys that are column numbers/names and values are HED tag prefixes to prepend to the tags in that column before processing. - May be deprecated. These are no longer prefixes, but rather converted to value columns. - eg. {"key": "Description"} will turn into a value column as {"key": "Description/#"} + May be deprecated/renamed. These are no longer prefixes, but rather converted to value columns. + eg. {"key": "Description", 1: "Label/"} will turn into value columns as + {"key": "Description/#", 1: "Label/#"} + Note: It will be a validation issue if column 1 is called "key" in the above example. This means it no longer accepts anything but the value portion only in the columns. optional_tag_columns (list): A list of ints or strings containing the columns that contain the HED tags. If the column is otherwise unspecified, convert this column type to HEDTags. @@ -36,12 +38,6 @@ def __init__(self, sidecar=None, tag_columns=None, column_prefix_dictionary=None Notes: - All column numbers are 0 based. - - Examples: - column_prefix_dictionary = {3: 'Description/', 4: 'Label/'} - - The third column contains tags that need Description/ tag prepended, while the fourth column - contains tag that needs Label/ prepended. """ # This points to column_type entries based on column names or indexes if columns have no column_name. self.column_data = {} @@ -79,9 +75,9 @@ def get_transformers(self): assign_to_column = column.column_name if isinstance(assign_to_column, int): if self._column_map: - assign_to_column = self._column_map[assign_to_column - 1] + assign_to_column = self._column_map[assign_to_column] else: - assign_to_column = assign_to_column - 1 + assign_to_column = assign_to_column if column.column_type == ColumnType.Ignore: continue elif column.column_type == ColumnType.Value: @@ -154,7 +150,7 @@ def get_tag_columns(self): column_identifiers(list): A list of column numbers or names that are ColumnType.HedTags. 0-based if integer-based, otherwise column name. """ - return [column_entry.column_name - 1 if isinstance(column_entry.column_name, int) else column_entry.column_name + return [column_entry.column_name if isinstance(column_entry.column_name, int) else column_entry.column_name for number, column_entry in self._final_column_map.items() if column_entry.column_type == ColumnType.HEDTags] @@ -263,6 +259,7 @@ def _add_column_data(self, new_column_entry): def _get_basic_final_map(column_map, column_data): basic_final_map = {} unhandled_names = {} + issues = [] if column_map: for column_number, column_name in column_map.items(): if column_name is None: @@ -277,11 +274,16 @@ def _get_basic_final_map(column_map, column_data): unhandled_names[column_name] = column_number for column_number in column_data: if isinstance(column_number, int): + if column_number in basic_final_map: + issues += ErrorHandler.format_error(ValidationErrors.DUPLICATE_NAME_NUMBER_COLUMN, + column_name=basic_final_map[column_number].column_name, + column_number=column_number) + continue column_entry = copy.deepcopy(column_data[column_number]) column_entry.column_name = column_number basic_final_map[column_number] = column_entry - return basic_final_map, unhandled_names + return basic_final_map, unhandled_names, issues @staticmethod def _convert_to_indexes(name_to_column_map, column_list): @@ -357,14 +359,15 @@ def _finalize_mapping(self): # 2. Add any tag columns and note issues about missing columns # 3. Add any numbered columns that have required prefixes # 4. Filter to just requested columns, if any - final_map, unhandled_names = self._get_basic_final_map(self._column_map, self.column_data) + final_map, unhandled_names, issues = self._get_basic_final_map(self._column_map, self.column_data) # convert all tag lists to indexes -> Issuing warnings at this time potentially for unknown ones - all_tag_columns, required_tag_columns, issues = self._convert_tag_columns(self._tag_columns, + all_tag_columns, required_tag_columns, tag_issues = self._convert_tag_columns(self._tag_columns, self._optional_tag_columns, self._requested_columns, self._reverse_column_map) + issues += tag_issues # Notes any missing required columns issues += self._add_tag_columns(final_map, unhandled_names, all_tag_columns, required_tag_columns, self._warn_on_missing_column) diff --git a/hed/models/spreadsheet_input.py b/hed/models/spreadsheet_input.py index b48f6985f..1c9b98520 100644 --- a/hed/models/spreadsheet_input.py +++ b/hed/models/spreadsheet_input.py @@ -16,16 +16,16 @@ def __init__(self, file=None, file_type=None, worksheet_name=None, tag_columns=N worksheet_name (str or None): The name of the Excel workbook worksheet that contains the HED tags. Not applicable to tsv files. If omitted for Excel, the first worksheet is assumed. tag_columns (list): A list of ints containing the columns that contain the HED tags. - The default value is [2] indicating only the second column has tags. + The default value is [1] indicating only the second column has tags. has_column_names (bool): True if file has column names. Validation will skip over the first line of the file if the spreadsheet as column names. - column_prefix_dictionary (dict): A dictionary with column number keys and prefix values. - This is partially deprecated - what this now turns the given columns into Value columns. - Examples: - A prefix dictionary {3: 'Label/', 5: 'Description/'} indicates that column 3 and 5 have HED tags - that need to be prefixed by Label/ and Description/ respectively. - Column numbers 3 and 5 should also be included in the tag_columns list. - + column_prefix_dictionary (dict): Dictionary with keys that are column numbers/names and values are HED tag + prefixes to prepend to the tags in that column before processing. + May be deprecated/renamed. These are no longer prefixes, but rather converted to value columns. + eg. {"key": "Description", 1: "Label/"} will turn into value columns as + {"key": "Description/#", 1: "Label/#"} + Note: It will be a validation issue if column 1 is called "key" in the above example. + This means it no longer accepts anything but the value portion only in the columns. """ if tag_columns is None: tag_columns = [1] diff --git a/hed/models/tabular_input.py b/hed/models/tabular_input.py index b32b22032..8a6d5c5f8 100644 --- a/hed/models/tabular_input.py +++ b/hed/models/tabular_input.py @@ -15,7 +15,6 @@ def __init__(self, file=None, sidecar=None, name=None): Parameters: file (str or file like): A tsv file to open. sidecar (str or Sidecar): A Sidecar filename or Sidecar - Note: If this is a string you MUST also pass hed_schema. name (str): The name to display for this file for error purposes. """ if sidecar and not isinstance(sidecar, Sidecar): diff --git a/tests/models/test_spreadsheet_input.py b/tests/models/test_spreadsheet_input.py index 4a507fa18..0c31f35d8 100644 --- a/tests/models/test_spreadsheet_input.py +++ b/tests/models/test_spreadsheet_input.py @@ -23,16 +23,6 @@ def setUpClass(cls): "../data/validator_tests/ExcelMultipleSheets.xlsx") cls.default_test_file_name = default cls.generic_file_input = SpreadsheetInput(default) - cls.integer_key_dictionary = {1: 'one', 2: 'two', 3: 'three'} - cls.one_based_tag_columns = [1, 2, 3] - cls.zero_based_tag_columns = [0, 1, 2, 3, 4] - cls.zero_based_row_column_count = 3 - cls.zero_based_tag_columns_less_than_row_column_count = [0, 1, 2] - cls.column_prefix_dictionary = {3: 'Event/Description/', 4: 'Event/Label/', 5: 'Event/Category/'} - cls.category_key = 'Event/Category/' - cls.category_participant_and_stimulus_tags = 'Event/Category/Participant response,Event/Category/Stimulus' - cls.category_tags = 'Participant response, Stimulus' - cls.row_with_hed_tags = ['event1', 'tag1', 'tag2'] base_output = os.path.join(os.path.dirname(os.path.realpath(__file__)), "../data/tests_output/") cls.base_output_folder = base_output os.makedirs(base_output, exist_ok=True) @@ -44,8 +34,8 @@ def tearDownClass(cls): def test_all(self): hed_input = self.default_test_file_name has_column_names = True - column_prefix_dictionary = {2: 'Label', 3: 'Description'} - tag_columns = [4] + column_prefix_dictionary = {1: 'Label/', 2: 'Description'} + tag_columns = [3] worksheet_name = 'LKT Events' file_input = SpreadsheetInput(hed_input, has_column_names=has_column_names, worksheet_name=worksheet_name, @@ -58,6 +48,25 @@ def test_all(self): # Just make sure this didn't crash for now self.assertTrue(True) + def test_all2(self): + # This should work, but raise an issue as Short label and column 1 overlap. + hed_input = self.default_test_file_name + has_column_names = True + column_prefix_dictionary = {1: 'Label/', "Short label": 'Description'} + tag_columns = [3] + worksheet_name = 'LKT Events' + + file_input = SpreadsheetInput(hed_input, has_column_names=has_column_names, worksheet_name=worksheet_name, + tag_columns=tag_columns, column_prefix_dictionary=column_prefix_dictionary) + + self.assertTrue(isinstance(file_input.dataframe_a, pd.DataFrame)) + self.assertTrue(isinstance(file_input.series_a, pd.Series)) + self.assertTrue(file_input.dataframe_a.size) + self.assertTrue(len(file_input._mapper.get_column_mapping_issues()), 1) + + # Just make sure this didn't crash for now + self.assertTrue(True) + def test_file_as_string(self): events_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../data/validator_tests/bids_events_no_index.tsv') @@ -103,8 +112,8 @@ def test_to_excel(self): def test_to_excel_should_work(self): spreadsheet = SpreadsheetInput(file=self.default_test_file_name, file_type='.xlsx', - tag_columns=[4], has_column_names=True, - column_prefix_dictionary={1: 'Label/', 3: 'Description/'}, + tag_columns=[3], has_column_names=True, + column_prefix_dictionary={1: 'Label/', 2: 'Description/'}, name='ExcelOneSheet.xlsx') buffer = io.BytesIO() spreadsheet.to_excel(buffer, output_assembled=True) @@ -148,51 +157,51 @@ def test_loading_and_reset_mapper(self): def test_no_column_header_and_convert(self): events_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../data/model_tests/no_column_header.tsv') - hed_input = SpreadsheetInput(events_path, has_column_names=False, tag_columns=[1, 2]) + hed_input = SpreadsheetInput(events_path, has_column_names=False, tag_columns=[0, 1]) hed_input.convert_to_long(self.hed_schema) events_path_long = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../data/model_tests/no_column_header_long.tsv') - hed_input_long = SpreadsheetInput(events_path_long, has_column_names=False, tag_columns=[1, 2]) + hed_input_long = SpreadsheetInput(events_path_long, has_column_names=False, tag_columns=[0, 1]) self.assertTrue(hed_input._dataframe.equals(hed_input_long._dataframe)) def test_convert_short_long_with_definitions(self): # Verify behavior works as expected even if definitions are present events_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../data/model_tests/no_column_header_definition.tsv') - hed_input = SpreadsheetInput(events_path, has_column_names=False, tag_columns=[1, 2]) + hed_input = SpreadsheetInput(events_path, has_column_names=False, tag_columns=[0, 1]) hed_input.convert_to_long(self.hed_schema) events_path_long = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../data/model_tests/no_column_header_definition_long.tsv') - hed_input_long = SpreadsheetInput(events_path_long, has_column_names=False, tag_columns=[1, 2]) + hed_input_long = SpreadsheetInput(events_path_long, has_column_names=False, tag_columns=[0, 1]) self.assertTrue(hed_input._dataframe.equals(hed_input_long._dataframe)) def test_definitions_identified(self): # Todo: this test is no longer relevant events_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../data/model_tests/no_column_header_definition.tsv') - hed_input = SpreadsheetInput(events_path, has_column_names=False, tag_columns=[1, 2]) + hed_input = SpreadsheetInput(events_path, has_column_names=False, tag_columns=[0, 1]) events_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../data/model_tests/no_column_header_definition.tsv') - hed_input = SpreadsheetInput(events_path, has_column_names=False, tag_columns=[1, 2]) + hed_input = SpreadsheetInput(events_path, has_column_names=False, tag_columns=[0, 1]) def test_loading_dataframe_directly(self): ds_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../data/model_tests/no_column_header_definition.tsv') ds = pd.read_csv(ds_path, delimiter="\t", header=None) - hed_input = SpreadsheetInput(ds, has_column_names=False, tag_columns=[1, 2]) + hed_input = SpreadsheetInput(ds, has_column_names=False, tag_columns=[0, 1]) events_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../data/model_tests/no_column_header_definition.tsv') - hed_input2 = SpreadsheetInput(events_path, has_column_names=False, tag_columns=[1, 2]) + hed_input2 = SpreadsheetInput(events_path, has_column_names=False, tag_columns=[0, 1]) self.assertTrue(hed_input._dataframe.equals(hed_input2._dataframe)) def test_ignoring_na_column(self): events_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../data/model_tests/na_tag_column.tsv') - hed_input = SpreadsheetInput(events_path, has_column_names=False, tag_columns=[1, 2]) + hed_input = SpreadsheetInput(events_path, has_column_names=False, tag_columns=[0, 1]) self.assertTrue(hed_input.dataframe_a.loc[1, 1] == 'n/a') def test_ignoring_na_value_column(self): From 9dcc412f42915dddc1b93ffa1c91fe542874c7cf Mon Sep 17 00:00:00 2001 From: IanCa Date: Fri, 19 May 2023 13:07:48 -0500 Subject: [PATCH 2/2] Add a quick spreadsheet validator test --- .../ExcelMultipleSheets.xlsx | Bin 0 -> 15284 bytes tests/validator/test_spreadsheet_validator.py | 37 +++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tests/data/spreadsheet_validator_tests/ExcelMultipleSheets.xlsx diff --git a/tests/data/spreadsheet_validator_tests/ExcelMultipleSheets.xlsx b/tests/data/spreadsheet_validator_tests/ExcelMultipleSheets.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..668af693b4cd84bf11ecc46d56074b33c82e526f GIT binary patch literal 15284 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