diff --git a/pyproject.toml b/pyproject.toml index 3639cc1..3099508 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dependencies = [ "lxml >= 4.6.2", "jinja2 >= 2.11.2", "xmlschema >= 2.4.0", + "openpyxl", ] [project.optional-dependencies] diff --git a/reqif/helpers/reqif_datetime.py b/reqif/helpers/reqif_datetime.py new file mode 100644 index 0000000..e631a28 --- /dev/null +++ b/reqif/helpers/reqif_datetime.py @@ -0,0 +1,39 @@ +import time +from datetime import datetime, timedelta, timezone + + +def create_reqif_datetime_now() -> str: + """ + FIXME: Maybe there is an easier way of create this. + """ + + # Get the local time and UTC offset + local_time = time.localtime() + utc_offset = timedelta( + seconds=-time.timezone if local_time.tm_isdst == 0 else -time.altzone + ) + + # Create a timezone object + local_timezone = timezone(utc_offset) + + # Get the current time in the local timezone + now = datetime.now(local_timezone) + + # Format the datetime in the ReqIF format + formatted_time = reqif_datetime_format(now) + + return formatted_time + + +def reqif_datetime_format(datetime_obj: datetime) -> str: + """ + Formats a date object to this format: + 2024-06-16T22:39:18.543+02:00 + FIXME: Maybe there is an easier way of create this. + """ + + formatted_time = datetime_obj.strftime("%Y-%m-%dT%H:%M:%S.%f%z") + formatted_time = ( + formatted_time[:23] + formatted_time[-5:-2] + ":" + formatted_time[-2:] + ) + return formatted_time diff --git a/reqif/models/reqif_namespace_info.py b/reqif/models/reqif_namespace_info.py index afc942c..019b20e 100644 --- a/reqif/models/reqif_namespace_info.py +++ b/reqif/models/reqif_namespace_info.py @@ -4,8 +4,10 @@ @auto_described -class ReqIFNamespaceInfo: # pylint: disable=too-many-instance-attributes - def __init__( # pylint: disable=too-many-arguments +class ReqIFNamespaceInfo: + REQIF_XSD = "http://www.omg.org/spec/ReqIF/20110401/reqif.xsd" + + def __init__( self, original_reqif_tag_dump: Optional[str], doctype_is_present: bool, @@ -46,3 +48,18 @@ def empty( schema_location=None, language=None, ) + + @staticmethod + def create_default(): + return ReqIFNamespaceInfo( + original_reqif_tag_dump=None, + doctype_is_present=True, + encoding="UTF-8", + namespace=ReqIFNamespaceInfo.REQIF_XSD, + configuration=None, + namespace_id=None, + namespace_xhtml="http://www.w3.org/1999/xhtml", + schema_namespace=None, + schema_location=None, + language=None, + ) diff --git a/reqif/models/reqif_reqif_header.py b/reqif/models/reqif_reqif_header.py index 7b778a4..c29affe 100644 --- a/reqif/models/reqif_reqif_header.py +++ b/reqif/models/reqif_reqif_header.py @@ -10,8 +10,8 @@ class EmptyTag: @auto_described -class ReqIFReqIFHeader: # pylint: disable=too-many-instance-attributes - def __init__( # pylint: disable=too-many-arguments +class ReqIFReqIFHeader: + def __init__( self, identifier: Optional[str] = None, comment: Optional[str] = None, diff --git a/reqif/parsers/attribute_value_parser.py b/reqif/parsers/attribute_value_parser.py index 8a2132b..a3319df 100644 --- a/reqif/parsers/attribute_value_parser.py +++ b/reqif/parsers/attribute_value_parser.py @@ -206,7 +206,7 @@ def unparse_attribute_values( output += " \n" for attribute in attribute_values: if attribute.attribute_type == SpecObjectAttributeType.STRING: - assert isinstance(attribute.value, str) + assert isinstance(attribute.value, str), attribute.value output += ATTRIBUTE_STRING_TEMPLATE.format( definition_ref=attribute.definition_ref, value=html.escape(attribute.value), diff --git a/reqif/reqif_bundle.py b/reqif/reqif_bundle.py index eae32a1..3457a16 100644 --- a/reqif/reqif_bundle.py +++ b/reqif/reqif_bundle.py @@ -19,7 +19,7 @@ @auto_described -class ReqIFBundle: # pylint: disable=too-many-instance-attributes +class ReqIFBundle: @staticmethod def create_empty( namespace: Optional[str], @@ -44,7 +44,7 @@ def __init__( tool_extensions_tag_exists: bool, lookup: ReqIFObjectLookup, exceptions: List[ReqIFSchemaError], - ): # pylint: disable=too-many-arguments + ): self.namespace_info: ReqIFNamespaceInfo = namespace_info self.req_if_header: Optional[ReqIFReqIFHeader] = req_if_header self.core_content: Optional[ReqIFCoreContent] = core_content diff --git a/tests/integration/examples/01_create_reqif_objects/script.py b/tests/integration/examples/01_create_reqif_objects/script.py index 99174ae..2bfd242 100644 --- a/tests/integration/examples/01_create_reqif_objects/script.py +++ b/tests/integration/examples/01_create_reqif_objects/script.py @@ -42,7 +42,7 @@ class ExampleIdentifiers: string_data_type = ReqIFDataTypeDefinitionString( - identifier=(ExampleIdentifiers.STRING_DATATYPE_ID), + identifier=ExampleIdentifiers.STRING_DATATYPE_ID, last_change=date_now, long_name="String type", max_length="50", @@ -51,7 +51,7 @@ class ExampleIdentifiers: requirement_text_attribute = SpecAttributeDefinition( attribute_type=SpecObjectAttributeType.STRING, identifier=ExampleIdentifiers.SPEC_ATTRIBUTE_ID, - datatype_definition=(ExampleIdentifiers.STRING_DATATYPE_ID), + datatype_definition=ExampleIdentifiers.STRING_DATATYPE_ID, last_change=date_now, long_name="Requirement text", ) diff --git a/tests/integration/examples/10_excel_convert_excel_to_reqif/input.xlsx b/tests/integration/examples/10_excel_convert_excel_to_reqif/input.xlsx new file mode 100644 index 0000000..e0824f1 Binary files /dev/null and b/tests/integration/examples/10_excel_convert_excel_to_reqif/input.xlsx differ diff --git a/tests/integration/examples/10_excel_convert_excel_to_reqif/script.py b/tests/integration/examples/10_excel_convert_excel_to_reqif/script.py new file mode 100644 index 0000000..15b6294 --- /dev/null +++ b/tests/integration/examples/10_excel_convert_excel_to_reqif/script.py @@ -0,0 +1,282 @@ +import argparse +import os +import uuid +from pathlib import Path +from typing import Dict, List + +from openpyxl import load_workbook + +from reqif.helpers.string.xhtml_indent import reqif_indent_xhtml_string +from reqif.models.reqif_core_content import ReqIFCoreContent +from reqif.models.reqif_data_type import ReqIFDataTypeDefinitionString +from reqif.models.reqif_namespace_info import ReqIFNamespaceInfo +from reqif.models.reqif_req_if_content import ReqIFReqIFContent +from reqif.models.reqif_reqif_header import ReqIFReqIFHeader +from reqif.models.reqif_spec_hierarchy import ReqIFSpecHierarchy +from reqif.models.reqif_spec_object import ReqIFSpecObject, SpecObjectAttribute +from reqif.models.reqif_spec_object_type import ( + ReqIFSpecObjectType, + SpecAttributeDefinition, +) +from reqif.models.reqif_specification import ReqIFSpecification +from reqif.models.reqif_specification_type import ReqIFSpecificationType +from reqif.models.reqif_types import SpecObjectAttributeType +from reqif.object_lookup import ReqIFObjectLookup +from reqif.reqif_bundle import ReqIFBundle +from reqif.unparser import ReqIFUnparser + + +# "_" is needed to make the ReqIF XML schema validator happy. +def create_uuid(): + return "_" + uuid.uuid4().hex + + +def convert_excel_to_reqif(path_to_excel: str) -> str: + """ + This conversion script only works against an Excel file supplied by a user. + This Excel file features a very basic field schema that has the following + traits: + - A node/object type column is not available, so this script treats all nodes + as Requirements. An outcome of this simplicity is that the exported document + has no Sections/Chapters, and the list of requirements is plain. + - Relations are stored in the first column called Inlinks. This script does + not convert these relations to ReqIF Spec Relations yet. + FIXME: Fix this. + """ + workbook = load_workbook(filename=path_to_excel) + + sheet = workbook.active + + date_now = "2023-01-01T00:00:00.000+02:00" + + """ + Create a single String data type. It will be used for all + Spec Attribute Definitions below. + """ + string_data_type = ReqIFDataTypeDefinitionString( + identifier=create_uuid(), + last_change=date_now, + long_name="String type", + max_length="50", + ) + + """ + Use the first row's column titles to create Spec Attribute Definitions which + are essentially requirement fields. + """ + requirement_spec_attributes: List[SpecAttributeDefinition] = [] + map_column_titles_to_requirement_spec_attributes: Dict[str, SpecAttributeDefinition] = {} + + # FIXME: This Excel has 13 requirement fields. + # Some fields further right contain irregular notes and meta information + # about the document. + total_requirement_fields = 13 + column_titles: List[str] = next(sheet.iter_rows(min_row=1, max_row=1, min_col=1, max_col=total_requirement_fields, values_only=True)) + for column_title_ in column_titles: + requirement_text_attribute = SpecAttributeDefinition( + attribute_type=SpecObjectAttributeType.XHTML, + identifier=create_uuid(), + datatype_definition=string_data_type.identifier, + last_change=date_now, + long_name=column_title_, + ) + requirement_spec_attributes.append(requirement_text_attribute) + map_column_titles_to_requirement_spec_attributes[ + column_title_ + ] = requirement_text_attribute + + """ + Create a Spec Object Type for a single Requirement type. + """ + spec_object_type = ReqIFSpecObjectType.create( + identifier=create_uuid(), + long_name="Requirement", + last_change=date_now, + attribute_definitions=requirement_spec_attributes, + ) + + """ + Iterate over all rows of the Excel file and create at the same time: + - Spec Objects which are single requirements that contain the actual row values. + - Spec Hierarchies which give the Spec Objects their place in the document. + """ + spec_objects: List[ReqIFSpecObject] = [] + spec_hierarchies: List[ReqIFSpecHierarchy] = [] + for row_ in sheet.iter_rows( + min_row=2, min_col=1, max_col=10, values_only=True + ): + spec_object_attributes: List[SpecObjectAttribute] = [] + + for row_value_idx_, row_value_ in enumerate(row_): + # Empty cells are ignored. + if row_value_ is None: + continue + + assert row_value_ is not None + column_field = column_titles[row_value_idx_] + spec_object_attribute = map_column_titles_to_requirement_spec_attributes[ + column_field + ] + row_value_ = f"{row_value_.strip()}" + spec_object_attribute = SpecObjectAttribute( + attribute_type=SpecObjectAttributeType.XHTML, + definition_ref=spec_object_attribute.identifier, + value=reqif_indent_xhtml_string(row_value_), + ) + spec_object_attributes.append(spec_object_attribute) + + spec_object = ReqIFSpecObject( + identifier=create_uuid(), + attributes=spec_object_attributes, + spec_object_type=spec_object_type.identifier, + last_change=date_now, + ) + spec_objects.append(spec_object) + + spec_hierarchy = ReqIFSpecHierarchy( + xml_node=None, + is_self_closed=False, + identifier=create_uuid(), + last_change=date_now, + long_name=None, + spec_object=spec_object.identifier, + children=[], + level=1, + ) + spec_hierarchies.append(spec_hierarchy) + + """ + Create a single specification type which is always needed even if there is + only one document. + """ + specification_type = ReqIFSpecificationType( + identifier=create_uuid(), + description=None, + last_change=date_now, + long_name="Software Requirements Specification Document", + spec_attributes=None, + spec_attribute_map={}, + is_self_closed=True, + ) + + """ + Create a single specification which is of the Specification Type created just before. + Specification corresponds to a document. + """ + specification = ReqIFSpecification( + identifier=create_uuid(), + last_change=date_now, + long_name="ReqIF library requirements specification", + # Empty tag is needed to make the XML schema validator happy. + values=[], + specification_type=specification_type.identifier, + children=spec_hierarchies, + ) + + """ + Create the ReqIF's ReqIF Content class that stores together all types, + objects, and the specification. + """ + reqif_content = ReqIFReqIFContent( + data_types=[string_data_type], + spec_types=[ + specification_type, + spec_object_type, + ], + spec_objects=spec_objects, + spec_relations=[], + specifications=[specification], + spec_relation_groups=[], + ) + + core_content = ReqIFCoreContent(req_if_content=reqif_content) + + """ + Create the ReqIF's Namespace Info and ReqIF Header classes that hold the + ReqIF XML metadata and the ReqIF file identification data. + """ + namespace_info = ReqIFNamespaceInfo.create_default() + + reqif_header = ReqIFReqIFHeader( + identifier=create_uuid(), + creation_time=date_now, + repository_id="https://github.com/strictdoc-project/reqif", + req_if_tool_id="Python ReqIF library", + req_if_version="1.0", + source_tool_id="Python script", + title=( + "An example ReqIF file created from Excel and Python objects " + "using reqif library" + ), + ) + + reqif_lookup = ReqIFObjectLookup.empty() + + """ + Create the final ReqIF bundle from all the objects created so far. + """ + bundle = ReqIFBundle( + namespace_info=namespace_info, + req_if_header=reqif_header, + core_content=core_content, + tool_extensions_tag_exists=False, + lookup=reqif_lookup, + exceptions=[], + ) + + """ + Use ReqIF Unparser to convert the ReqIF bundle from Python objects to an XML string. + """ + reqif_string_content: str = ReqIFUnparser.unparse(bundle) + return reqif_string_content + + +def main(): + main_parser = argparse.ArgumentParser() + + main_parser.add_argument( + "input_file", type=str, help="Path to the input ReqIF file." + ) + main_parser.add_argument( + "--output-dir", + type=str, + help="Path to the output dir.", + default="output/", + ) + main_parser.add_argument( + "--stdout", + help="Makes the script write the output ReqIF to standard output.", + action="store_true", + ) + main_parser.add_argument( + "--no-filesystem", + help=( + "Disables writing to the file system. " + "Should be used in combination with --stdout." + ), + action="store_true", + ) + + args = main_parser.parse_args() + input_file: str = args.input_file + input_file_name = Path(input_file).stem + should_use_file_system: bool = not args.no_filesystem + should_use_stdout: bool = args.stdout + + reqif_string_content: str = convert_excel_to_reqif(input_file) + + if should_use_file_system: + path_to_output_dir = args.output_dir + Path(path_to_output_dir).mkdir(exist_ok=True) + + path_to_output_file = os.path.join( + path_to_output_dir, f"{input_file_name}.reqif" + ) + with open(path_to_output_file, "w") as output_reqif_file: + output_reqif_file.write(reqif_string_content) + + if should_use_stdout: + print(reqif_string_content) # noqa: T201 + + +main() diff --git a/tests/integration/examples/10_excel_convert_excel_to_reqif/test.itest b/tests/integration/examples/10_excel_convert_excel_to_reqif/test.itest new file mode 100644 index 0000000..85ea219 --- /dev/null +++ b/tests/integration/examples/10_excel_convert_excel_to_reqif/test.itest @@ -0,0 +1,4 @@ +RUN: mkdir -p %S/Output + +RUN: python %S/script.py %S/input.xlsx --output-dir %S/Output/reqif +_UN: %excel_diff %S/input.xlsx %S/Output/your_modified_file.xlsx diff --git a/tests/integration/excel_diff.py b/tests/integration/excel_diff.py new file mode 100644 index 0000000..8c1c1c0 --- /dev/null +++ b/tests/integration/excel_diff.py @@ -0,0 +1,53 @@ +import argparse +import os +import sys + +from openpyxl import load_workbook + +arg_parser = argparse.ArgumentParser() + +arg_parser.add_argument("lhs_file", type=str, help="") + +arg_parser.add_argument("rhs_file", type=str, help="") + +args = arg_parser.parse_args() + +if not os.path.exists(args.lhs_file): + print( # noqa: T201 + f"error: path does not exist: {args.lhs_file}", file=sys.stderr + ) + exit(1) +if not os.path.exists(args.rhs_file): + print( # noqa: T201 + f"error: path does not exist: {args.rhs_file}", file=sys.stderr + ) + exit(1) + +lhs_wb = load_workbook(args.lhs_file) +rhs_wb = load_workbook(args.rhs_file) + +lhs_sheet = lhs_wb.active +rhs_sheet = rhs_wb.active + +if lhs_sheet.max_row != rhs_sheet.max_row: + print("Excel files have different number of rows") # noqa: T201 + exit(1) +if lhs_sheet.max_column != rhs_sheet.max_column: + print("Excel files have different number of columns") # noqa: T201 + exit(1) + +errors = [] +for row_num in range(1, lhs_sheet.max_row): + for col_num in range(1, lhs_sheet.max_column): + lhs_cell = lhs_sheet.cell(row=row_num, column=col_num) + rhs_cell = rhs_sheet.cell(row=row_num, column=col_num) + if lhs_cell.value != rhs_cell.value: + errors.append( + f"Cell {lhs_cell}: '{lhs_cell.value}' != '{rhs_cell.value}'" + ) + +if errors: + print("Excel Diff: files are not equal:") # noqa: T201 + for error in errors: + print(error) # noqa: T201 + exit(1) diff --git a/tests/integration/lit.cfg b/tests/integration/lit.cfg index 8c7fdc2..f2a078a 100644 --- a/tests/integration/lit.cfg +++ b/tests/integration/lit.cfg @@ -61,6 +61,7 @@ config.substitutions.append(('%cat', cat_exec)) config.substitutions.append(('%compare_zip_files', 'python \"{}/tests/integration/compare_zip_files.py\"'.format(current_dir))) config.substitutions.append(('%diff', 'diff --strip-trailing-cr'.format(current_dir))) +config.substitutions.append(('%excel_diff', 'python \"{}/tests/integration/excel_diff.py\"'.format(current_dir))) config.substitutions.append(('%expect_exit', 'python \"{}/tests/integration/expect_exit.py\"'.format(current_dir))) config.substitutions.append(('%check_exists', 'python \"{}/tests/integration/check_exists.py\"'.format(current_dir))) config.substitutions.append(('%printf', 'python \"{}/tests/integration/tools/printf.py\"'.format(current_dir))) diff --git a/tests/unit/reqif/helpers/test_reqif_datetime.py b/tests/unit/reqif/helpers/test_reqif_datetime.py new file mode 100644 index 0000000..d9973a9 --- /dev/null +++ b/tests/unit/reqif/helpers/test_reqif_datetime.py @@ -0,0 +1,36 @@ +import datetime +import re + +from reqif.helpers.reqif_datetime import ( + create_reqif_datetime_now, + reqif_datetime_format, +) + +PATTERN = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$" + + +def test_reqif_datetime_format_tz_plus() -> None: + date = datetime.datetime(2018, 6, 6, 23, 59, 59, 999000) + + utc_plus_2 = datetime.timezone(datetime.timedelta(hours=2)) + date = date.replace(tzinfo=utc_plus_2) + + reqif_datetime = reqif_datetime_format(date) + assert reqif_datetime == "2018-06-06T23:59:59.999+02:00" + assert re.match(PATTERN, reqif_datetime) + + +def test_reqif_datetime_format_tz_minus() -> None: + date = datetime.datetime(2018, 6, 6, 23, 59, 59, 999000) + + utc_minus_2 = datetime.timezone(datetime.timedelta(hours=-2)) + date = date.replace(tzinfo=utc_minus_2) + + reqif_datetime = reqif_datetime_format(date) + assert reqif_datetime == "2018-06-06T23:59:59.999-02:00" + assert re.match(PATTERN, reqif_datetime) + + +def test_create_reqif_datetime_now() -> None: + date = create_reqif_datetime_now() + assert re.match(PATTERN, date)