From cd9cbb75f1e05acfee2c0b828d84454888594f72 Mon Sep 17 00:00:00 2001 From: Johannes Nussbaum <39048939+jnussbaum@users.noreply.github.com> Date: Thu, 25 May 2023 15:52:44 +0200 Subject: [PATCH] refactor: refactor CLI part of excel2xml (DEV-2190) (#384) --- src/dsp_tools/excel2xml.py | 600 +++++++++++++----- test/unittests/test_excel2xml.py | 11 +- .../excel2xml/start-with-property-row.xlsx | Bin 0 -> 59254 bytes 3 files changed, 449 insertions(+), 162 deletions(-) create mode 100644 testdata/invalid-testdata/excel2xml/start-with-property-row.xlsx diff --git a/src/dsp_tools/excel2xml.py b/src/dsp_tools/excel2xml.py index 43eba062b..841ed0f25 100644 --- a/src/dsp_tools/excel2xml.py +++ b/src/dsp_tools/excel2xml.py @@ -8,8 +8,7 @@ import re import uuid import warnings -from operator import xor -from typing import Any, Iterable, Optional, Union +from typing import Any, Callable, Iterable, Optional, Union import pandas as pd import regex @@ -1766,32 +1765,264 @@ def write_xml(root: etree._Element, filepath: str) -> None: warnings.warn(f"The XML file was successfully saved to {filepath}, but the following Schema validation error(s) occurred: {err.message}") -def excel2xml(datafile: str, shortcode: str, default_ontology: str) -> bool: +def _read_cli_input_file(datafile: str) -> pd.DataFrame: """ - This is a method that is called from the command line. It isn't intended to be used in a Python script. It takes a - tabular data source in CSV/XLS(X) format that is formatted according to the specifications, and transforms it to DSP- - conforming XML file that can be uploaded to a DSP server with the xmlupload command. The output file is saved in the - same directory as the input file, with the name [default_ontology]-data.xml. + Parse the input file from the CLI (in either CSV or Excel format) - Please note that this method doesn't do any data cleaning or data transformation tasks. The input and the output of - this method are semantically exactly equivalent. + Args: + datafile: path to the input file + + Raises: + BaseError: if the input file is neither .csv, .xls nor .xlsx + + Returns: + a pandas DataFrame with the input data + """ + if re.search(r"\.csv$", datafile): + dataframe = pd.read_csv( + filepath_or_buffer=datafile, + encoding="utf_8_sig", # utf_8_sig is the default encoding of Excel + dtype="str", + sep=None, + engine="python" # let the "python" engine detect the separator + ) + elif re.search(r"(\.xls|\.xlsx)$", datafile): + dataframe = pd.read_excel( + io=datafile, + dtype="str" + ) + else: + raise BaseError(f"Cannot open file '{datafile}': Invalid extension. Allowed extensions: 'csv', 'xls', 'xlsx'") + return dataframe + + +def _validate_and_prepare_cli_input_file(dataframe: pd.DataFrame) -> pd.DataFrame: + """ + Make sure that the required columns are present, + replace NA-like cells by NA, + remove empty columns, so that the max_num_of_props can be calculated without errors, + and remove empty rows, to prevent them from being processed and raising an error. Args: - datafile: path to the data file (CSV or XLS(X)) - shortcode: shortcode of the project that this data belongs to - default_ontology: name of the ontology that this data belongs to + dataframe: pandas dataframe with the input data Raises: - BaseError if something went wrong + BaseError: if one of the required columns is missing Returns: - True if everything went well, False otherwise + the prepared pandas DataFrame """ + # make sure that the required columns are present + required_columns = ["id", "label", "restype", "permissions", "prop name", "prop type", "1_value"] + if any(req not in dataframe for req in required_columns): + raise BaseError(f"Some columns in your input file are missing. The following columns are required: {required_columns}") - # general preparation - # ------------------- - success = True - proptype_2_function = { + # replace NA-like cells by NA + dataframe = dataframe.applymap( + lambda x: x if pd.notna(x) and regex.search(r"[\p{L}\d_!?\-]", str(x), flags=regex.U) else pd.NA + ) + + # remove empty columns/rows + dataframe.dropna(axis="columns", how="all", inplace=True) + dataframe.dropna(axis="index", how="all", inplace=True) + + return dataframe + + +def _convert_rows_to_xml( + dataframe: pd.DataFrame, + max_num_of_props: int +) -> list[etree._Element]: + """ + Iterate through the rows of the CSV/Excel input file, + convert every row to either a XML resource or an XML property, + and return a list of XML resources. + + Args: + dataframe: pandas dataframe with the input data + max_num_of_props: highest number of properties that a resource in this file has + + Raises: + BaseError: if one of the rows is neither a resource-row nor a property-row, or if the file starts with a property-row + + Returns: + a list of XML resources (with their respective properties) + """ + resources: list[etree._Element] = [] + + # to start, there is no previous resource + resource: Optional[etree._Element] = None + + for index, row in dataframe.iterrows(): + row_number = int(str(index)) + 2 + # either the row is a resource-row or a property-row, but not both + if check_notna(row["id"]) == check_notna(row["prop name"]): + raise BaseError( + f"Exactly 1 of the 2 columns 'id' and 'prop name' must be filled. " + f"Excel row {row_number} has too many/too less entries:\n" + f"id: '{row['id']}'\n" + f"prop name: '{row['prop name']}'" + ) + + # this is a resource-row + elif check_notna(row["id"]): + # the previous resource is finished, a new resource begins: append the previous to the resulting list + # in all cases (except for the very first iteration), a previous resource exists + if resource is not None: + resources.append(resource) + resource = _convert_resource_row_to_xml( + row_number=row_number, + row=row + ) + + # this is a property-row + else: + assert check_notna(row["prop name"]) + if resource is None: + raise BaseError("The first row of your Excel/CSV is invalid. The first row must define a resource, not a property.") + prop = _convert_property_row_to_xml( + row_number=row_number, + row=row, + max_num_of_props=max_num_of_props, + resource_id=resource.attrib["id"] + ) + resource.append(prop) + + # append the resource of the very last iteration of the for loop + if resource is not None: + resources.append(resource) + + return resources + + +def _append_bitstream_to_resource( + resource: etree._Element, + row: pd.Series, + row_number: int +) -> etree._Element: + """ + Create a bitstream-prop element, and append it to the resource. + If the file permissions are missing, try to deduce them from the resource permissions. + + Args: + resource: the resource element to which the bitstream-prop element should be appended + row: the row of the CSV/Excel file from where all information comes from + row_number: row number of the CSV/Excel sheet + + Raises: + BaseError: if the file permissions are missing and cannot be deduced from the resource permissions + + Returns: + the resource element with the appended bitstream-prop element + """ + file_permissions = row.get("file permissions") + if not check_notna(file_permissions): + resource_permissions = row.get("permissions") + if resource_permissions == "res-default": + file_permissions = "prop-default" + elif resource_permissions == "res-restricted": + file_permissions = "prop-restricted" + else: + raise BaseError( + f"Missing file permissions for file '{row['file']}' (Resource ID '{row['id']}', Excel row {row_number}). " + f"An attempt to deduce them from the resource permissions failed." + ) + resource.append( + make_bitstream_prop( + path=str(row["file"]), + permissions=str(file_permissions), + calling_resource=row["id"] + ) + ) + return resource + + +def _convert_resource_row_to_xml( + row_number: int, + row: pd.Series +) -> etree._Element: + """ + Convert a resource-row to an XML resource element. + First, check if the mandatory cells are present. + Then, call the appropriate function, depending on the restype (Resource, LinkObj, Annotation, Region). + + Args: + row_number: row number of the CSV/Excel sheet + row: the pandas series representing the current row + + Raises: + BaseError: if a mandatory cell is missing + + Returns: + the resource element created from the row + """ + # read and check the mandatory columns + resource_id = row["id"] + resource_label = row.get("label") + if pd.isna([resource_label]): + raise BaseError(f"Missing label for resource '{resource_id}' (Excel row {row_number})") + if not check_notna(resource_label): + warnings.warn(f"The label of resource '{resource_id}' looks suspicious: '{resource_label}' (Excel row {row_number})") + resource_restype = row.get("restype") + if not check_notna(resource_restype): + raise BaseError(f"Missing restype for resource '{resource_id}' (Excel row {row_number})") + resource_permissions = row.get("permissions") + if not check_notna(resource_permissions): + raise BaseError(f"Missing permissions for resource '{resource_id}' (Excel row {row_number})") + + # construct the kwargs for the method call + kwargs_resource = { + "label": resource_label, + "permissions": resource_permissions, + "id": resource_id + } + if check_notna(row.get("ark")): + kwargs_resource["ark"] = row["ark"] + if check_notna(row.get("iri")): + kwargs_resource["iri"] = row["iri"] + if check_notna(row.get("ark")) and check_notna(row.get("iri")): + warnings.warn(f"Both ARK and IRI were provided for resource '{resource_label}' ({resource_id}). The ARK will override the IRI.") + if check_notna(row.get("created")): + kwargs_resource["creation_date"] = row["created"] + + # call the appropriate method + if resource_restype == "Region": + resource = make_region(**kwargs_resource) + elif resource_restype == "Annotation": + resource = make_annotation(**kwargs_resource) + elif resource_restype == "LinkObj": + resource = make_link(**kwargs_resource) + else: + kwargs_resource["restype"] = resource_restype + resource = make_resource(**kwargs_resource) + if check_notna(row.get("file")): + resource = _append_bitstream_to_resource( + resource=resource, + row=row, + row_number=row_number + ) + + return resource + + +def _get_prop_function( + row: pd.Series, + resource_id: str +) -> Callable[..., etree._Element]: + """ + Return the function that creates the appropriate property, depending on the proptype. + + Args: + row: row of the CSV/Excel sheet that defines the property + resource_id: resource ID of the resource to which the property belongs + + Raises: + BaseError: if the proptype is invalid + + Returns: + the function that creates the appropriate property + """ + proptype_2_function: dict[str, Callable[..., etree._Element]] = { "bitstream": make_bitstream_prop, "boolean-prop": make_boolean_prop, "color-prop": make_color_prop, @@ -1806,152 +2037,207 @@ def excel2xml(datafile: str, shortcode: str, default_ontology: str) -> bool: "text-prop": make_text_prop, "uri-prop": make_uri_prop } - if re.search(r"\.csv$", datafile): - # "utf_8_sig": an optional BOM at the start of the file will be skipped - # let the "python" engine detect the separator - main_df = pd.read_csv(datafile, encoding="utf_8_sig", dtype="str", sep=None, engine="python") - elif re.search(r"(\.xls|\.xlsx)$", datafile): - main_df = pd.read_excel(datafile, dtype="str") - else: - raise BaseError("The argument 'datafile' must have one of the extensions 'csv', 'xls', 'xlsx'") - # replace NA-like cells by NA - main_df = main_df.applymap( - lambda x: x if pd.notna(x) and regex.search(r"[\p{L}\d_!?\-]", str(x), flags=regex.U) else pd.NA + if row.get("prop type") not in proptype_2_function: + raise BaseError(f"Invalid prop type for property {row.get('prop name')} in resource {resource_id}") + make_prop_function = proptype_2_function[row["prop type"]] + return make_prop_function + + +def _convert_row_to_property_elements( + row: pd.Series, + max_num_of_props: int, + row_number: int, + resource_id: str +) -> list[PropertyElement]: + """ + Every property contains i elements, + which are represented in the Excel as groups of columns named + {i_value, i_encoding, i_permissions, i_comment}. + Depending on the property type, some of these cells are empty. + This method converts a row to a list of PropertyElement objects. + + Args: + row: the pandas series representing the current row + max_num_of_props: highest number of properties that a resource in this file has + row_number: row number of the CSV/Excel sheet + resource_id: id of resource to which this property belongs to + + Raises: + BaseError: if a mandatory cell is missing, or if there are too many/too few values per property + + Returns: + list of PropertyElement objects + """ + property_elements: list[PropertyElement] = [] + for i in range(1, max_num_of_props + 1): + value = row[f"{i}_value"] + if pd.isna(value): + # raise error if other cells of this property element are not empty + # if all other cells are empty, continue with next property element + other_cell_headers = [f"{i}_{x}" for x in ["encoding", "permissions", "comment"]] + notna_cell_headers = [x for x in other_cell_headers if check_notna(row.get(x))] + notna_cell_headers_str = ", ".join([f"'{x}'" for x in notna_cell_headers]) + if notna_cell_headers_str: + raise BaseError( + f"Error in resource '{resource_id}': Excel row {row_number} has an entry in column(s) {notna_cell_headers_str}, " + f"but not in '{i}_value'. " + r"Please note that cell contents that don't meet the requirements of the regex [\p{L}\d_!?\-] are considered inexistent." + ) + continue + + # construct a PropertyElement from this property element + kwargs_propelem = { + "value": value, + "permissions": str(row.get(f"{i}_permissions")) + } + if not check_notna(row.get(f"{i}_permissions")): + raise BaseError(f"Missing permissions in column '{i}_permissions' of property '{row['prop name']}' in resource with id '{resource_id}'") + if check_notna(row.get(f"{i}_comment")): + kwargs_propelem["comment"] = str(row[f"{i}_comment"]) + if check_notna(row.get(f"{i}_encoding")): + kwargs_propelem["encoding"] = str(row[f"{i}_encoding"]) + property_elements.append(PropertyElement(**kwargs_propelem)) + + # validate the end result before returning it + if len(property_elements) == 0: + raise BaseError(f"At least one value per property is required, " + f"but resource '{resource_id}' (Excel row {row_number}) doesn't contain any values.") + if row.get("prop type") == "boolean-prop" and len(property_elements) != 1: + raise BaseError(f"A can only have a single value, " + f"but resource '{resource_id}' (Excel row {row_number}) contains more than one value.") + + return property_elements + + +def _convert_property_row_to_xml( + row_number: int, + row: pd.Series, + max_num_of_props: int, + resource_id: str +) -> etree._Element: + """ + Convert a property-row of the CSV/Excel sheet to an XML element. + + Args: + row_number: row number of the CSV/Excel sheet + row: the pandas series representing the current row + max_num_of_props: highest number of properties that a resource in this file has + resource_id: id of the resource to which the property will be appended + + Raises: + BaseError: if there is inconsistent data in the row / if a validation fails + + Returns: + the resource element with the appended property + """ + # based on the property type, the right function has to be chosen + make_prop_function = _get_prop_function( + row=row, + resource_id=resource_id + ) + + # convert the row to a list of PropertyElement objects + property_elements = _convert_row_to_property_elements( + row=row, + max_num_of_props=max_num_of_props, + row_number=row_number, + resource_id=resource_id + ) + + # create the property + prop = _create_property( + make_prop_function=make_prop_function, + row=row, + property_elements=property_elements, + resource_id=resource_id ) - # remove empty columns, so that the max_prop_count can be calculated without errors - main_df.dropna(axis="columns", how="all", inplace=True) - # remove empty rows, to prevent them from being processed and raising an error - main_df.dropna(axis="index", how="all", inplace=True) - max_prop_count = int(str(list(main_df)[-1]).split("_")[0]) + return prop + + +def _create_property( + make_prop_function: Callable[..., etree._Element], + row: pd.Series, + property_elements: list[PropertyElement], + resource_id: str +) -> etree._Element: + """ + Create a property based on the appropriate function and the property elements. + + Args: + make_prop_function: the function to create the property + row: the pandas series representing the current row of the Excel/CSV + property_elements: the list of PropertyElement objects + resource_id: id of resource to which this property belongs to + + Returns: + the resource with the properties appended + """ + kwargs_propfunc: dict[str, Union[str, PropertyElement, list[PropertyElement]]] = { + "name": row["prop name"], + "calling_resource": resource_id + } + + if row.get("prop type") == "boolean-prop": + kwargs_propfunc["value"] = property_elements[0] + else: + kwargs_propfunc["value"] = property_elements + + if check_notna(row.get("prop list")): + kwargs_propfunc["list_name"] = str(row["prop list"]) + + prop = make_prop_function(**kwargs_propfunc) + + return prop + + +def excel2xml( + datafile: str, + shortcode: str, + default_ontology: str +) -> bool: + """ + This is a method that is called from the command line. + It isn't intended to be used in a Python script. + It takes a tabular data source in CSV/XLS(X) format that is formatted according to the specifications, + and transforms it into a DSP-conforming XML file + that can be uploaded to a DSP server with the xmlupload command. + The output file is saved in the same directory as the input file, + with the name [default_ontology]-data.xml. + + Please note that this method doesn't do any data cleaning or data transformation tasks. + The input and the output of this method are semantically exactly equivalent. + + Args: + datafile: path to the data file (CSV or XLS(X)) + shortcode: shortcode of the project that this data belongs to + default_ontology: name of the ontology that this data belongs to + + Raises: + BaseError if something went wrong + + Returns: + True if everything went well, False otherwise + """ + # read and prepare the input file + success = True + dataframe = _read_cli_input_file(datafile) + dataframe = _validate_and_prepare_cli_input_file(dataframe) + last_column_title = str(list(dataframe)[-1]) # last column title, in the format "i_comment" + max_num_of_props = int(last_column_title.split("_")[0]) + + # create the XML root element root = make_root(shortcode=shortcode, default_ontology=default_ontology) root = append_permissions(root) - resource_id: str = "" - - # create all resources - # -------------------- - resource = None - for index, row in main_df.iterrows(): - - # there are two cases: either the row is a resource-row or a property-row. - if not xor(check_notna(row.get("id")), check_notna(row.get("prop name"))): - raise BaseError(f"Exactly 1 of the 2 columns 'id' and 'prop name' must have an entry. " - f"Excel row no. {int(str(index)) + 2} has too many/too less entries:\n" - f"id: '{row.get('id')}'\n" - f"prop name: '{row.get('prop name')}'") - - ########### case resource-row ########### - if check_notna(row.get("id")): - resource_id = row["id"] - resource_permissions = row.get("permissions") - if not check_notna(resource_permissions): - raise BaseError(f"Missing permissions for resource {resource_id}") - resource_label = row.get("label") - if not check_notna(resource_label): - raise BaseError(f"Missing label for resource {resource_id}") - resource_restype = row.get("restype") - if not check_notna(resource_restype): - raise BaseError(f"Missing restype for resource {resource_id}") - if check_notna(row.get("ark")) and check_notna(row.get("iri")): - raise BaseError(f"Both ARK and IRI were provided for resource '{resource_label}' ({resource_id}). The ARK will override the IRI.") - # previous resource is finished, now a new resource begins. in all cases (except for - # the very first iteration), a previous resource exists. if it exists, append it to root. - if resource is not None: - root.append(resource) - kwargs_resource = { - "label": resource_label, - "permissions": resource_permissions, - "id": resource_id - } - if check_notna(row.get("ark")): - kwargs_resource["ark"] = row["ark"] - if check_notna(row.get("iri")): - kwargs_resource["iri"] = row["iri"] - if check_notna(row.get("created")): - kwargs_resource["creation_date"] = row["created"] - if resource_restype not in ["Annotation", "Region", "LinkObj"]: - kwargs_resource["restype"] = resource_restype - resource = make_resource(**kwargs_resource) - if check_notna(row.get("file")): - file_permissions = row.get("file permissions") - if not check_notna(file_permissions): - if resource_permissions == "res-default": - file_permissions = "prop-default" - elif resource_permissions == "res-restricted": - file_permissions = "prop-restricted" - else: - raise BaseError(f"'file permissions' missing for file '{row['file']}' (Excel row {int(str(index)) + 2}). " - f"An attempt to deduce them from the resource permissions failed.") - resource.append(make_bitstream_prop( - path=str(row["file"]), - permissions=str(file_permissions), - calling_resource=resource_id - )) - elif resource_restype == "Region": - resource = make_region(**kwargs_resource) - elif resource_restype == "Annotation": - resource = make_annotation(**kwargs_resource) - elif resource_restype == "LinkObj": - resource = make_link(**kwargs_resource) - - ########### case property-row ########### - else: # check_notna(row["prop name"]): - # based on the property type, the right function has to be chosen - if row.get("prop type") not in proptype_2_function: - raise BaseError(f"Invalid prop type for property {row.get('prop name')} in resource {resource_id}") - make_prop_function = proptype_2_function[row["prop type"]] - - # every property contains i elements, which are represented in the Excel as groups of - # columns named {i_value, i_encoding, i_res ref, i_permissions, i_comment}. Depending - # on the property type, some of these items are NA. - # Thus, prepare list of PropertyElement objects, with each PropertyElement containing only - # the existing items. - property_elements: list[PropertyElement] = [] - for i in range(1, max_prop_count + 1): - value = row[f"{i}_value"] - if pd.notna(value): - kwargs_propelem = { - "value": value, - "permissions": str(row.get(f"{i}_permissions")) - } - if not check_notna(row.get(f"{i}_permissions")): - raise BaseError(f"Missing permissions for value {value} of property {row['prop name']} in resource {resource_id}") - if check_notna(row.get(f"{i}_comment")): - kwargs_propelem["comment"] = str(row[f"{i}_comment"]) - if check_notna(row.get(f"{i}_encoding")): - kwargs_propelem["encoding"] = str(row[f"{i}_encoding"]) - property_elements.append(PropertyElement(**kwargs_propelem)) - elif check_notna(str(row.get(f"{i}_permissions"))): - raise BaseError( - f"Excel row {int(str(index)) + 2} has an entry in column {i}_permissions, but not in {i}_value. " - r"Please note that cell contents that don't meet the requirements of the regex [\p{L}\d_!?\-] are considered inexistent." - ) - - # validate property_elements - if len(property_elements) == 0: - raise BaseError(f"At least one value per property is required, but Excel row {int(str(index)) + 2} doesn't contain any values.") - if make_prop_function == make_boolean_prop and len(property_elements) != 1: # pylint: disable=comparison-with-callable - raise BaseError(f"A can only have a single value, but Excel row {int(str(index)) + 2} contains more than one value.") - - # create the property and append it to resource - kwargs_propfunc: dict[str, Union[str, PropertyElement, list[PropertyElement]]] = { - "name": row["prop name"], - "calling_resource": resource_id - } - if make_prop_function == make_boolean_prop: # pylint: disable=comparison-with-callable - kwargs_propfunc["value"] = property_elements[0] - else: - kwargs_propfunc["value"] = property_elements - if check_notna(row.get("prop list")): - kwargs_propfunc["list_name"] = str(row["prop list"]) - - resource.append(make_prop_function(**kwargs_propfunc)) # type: ignore - # append the resource of the very last iteration of the for loop - if resource: + # parse the input file row by row + resources = _convert_rows_to_xml( + dataframe=dataframe, + max_num_of_props=max_num_of_props + ) + for resource in resources: root.append(resource) # write file - # ---------- with warnings.catch_warnings(record=True) as w: write_xml(root, f"{default_ontology}-data.xml") if len(w) > 0: diff --git a/test/unittests/test_excel2xml.py b/test/unittests/test_excel2xml.py index ead7ea035..f0afd233a 100644 --- a/test/unittests/test_excel2xml.py +++ b/test/unittests/test_excel2xml.py @@ -633,15 +633,16 @@ def test_excel2xml(self) -> None: invalid_cases = [ (f"{invalid_prefix}/boolean-prop-two-values.xlsx", "A can only have a single value"), (f"{invalid_prefix}/empty-property.xlsx", "At least one value per property is required"), - (f"{invalid_prefix}/id-propname-both.xlsx", "Exactly 1 of the 2 columns 'id' and 'prop name' must have an entry"), - (f"{invalid_prefix}/id-propname-none.xlsx", "Exactly 1 of the 2 columns 'id' and 'prop name' must have an entry"), - (f"{invalid_prefix}/missing-prop-permissions.xlsx", "Missing permissions for value .+ of property"), + (f"{invalid_prefix}/id-propname-both.xlsx", "Exactly 1 of the 2 columns 'id' and 'prop name' must be filled"), + (f"{invalid_prefix}/id-propname-none.xlsx", "Exactly 1 of the 2 columns 'id' and 'prop name' must be filled"), + (f"{invalid_prefix}/missing-prop-permissions.xlsx", "Missing permissions in column '2_permissions' of property ':hasName'"), (f"{invalid_prefix}/missing-resource-label.xlsx", "Missing label for resource"), (f"{invalid_prefix}/missing-resource-permissions.xlsx", "Missing permissions for resource"), (f"{invalid_prefix}/missing-restype.xlsx", "Missing restype"), - (f"{invalid_prefix}/no-bitstream-permissions.xlsx", "'file permissions' missing"), + (f"{invalid_prefix}/no-bitstream-permissions.xlsx", "Missing file permissions"), (f"{invalid_prefix}/nonexisting-proptype.xlsx", "Invalid prop type"), - (f"{invalid_prefix}/single-invalid-value-for-property.xlsx", "has an entry in column \\d+_permissions, but not in \\d+_value") + (f"{invalid_prefix}/single-invalid-value-for-property.xlsx", "row 3 has an entry in column.+ '1_encoding', '1_permissions', but not"), + (f"{invalid_prefix}/start-with-property-row.xlsx", "The first row must define a resource, not a property"), ] for file, _regex in invalid_cases: with self.assertRaisesRegex(BaseError, _regex, msg=f"Failed with file '{file}'"): diff --git a/testdata/invalid-testdata/excel2xml/start-with-property-row.xlsx b/testdata/invalid-testdata/excel2xml/start-with-property-row.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..7341e87d5cc26311452043f8de5ed5be0aadccf9 GIT binary patch literal 59254 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 literal 0 HcmV?d00001