|
| 1 | +# Copyright © LFV |
| 2 | + |
| 3 | +import gzip |
| 4 | +import io |
| 5 | +import os |
| 6 | +import tarfile |
| 7 | +import tempfile |
| 8 | +from importlib.metadata import PackageNotFoundError, version |
| 9 | +from pathlib import Path |
| 10 | +from typing import Any, Dict, List, Optional |
| 11 | + |
| 12 | +from hatchling.builders.hooks.plugin.interface import BuildHookInterface |
| 13 | +from hatchling.builders.plugin.interface import BuilderInterface |
| 14 | +from reqstool_python_decorators.processors.decorator_processor import DecoratorProcessor |
| 15 | +from ruamel.yaml import YAML |
| 16 | + |
| 17 | + |
| 18 | +class ReqstoolBuildHook(BuildHookInterface): |
| 19 | + """ |
| 20 | + Build hook that creates reqstool |
| 21 | +
|
| 22 | + 1. annotations files based on reqstool decorators |
| 23 | + 2. artifact reqstool-tar.gz file to be uploaded by hatch publish to pypi repo |
| 24 | +
|
| 25 | + Attributes: |
| 26 | + PLUGIN_NAME (str): The name of the plugin, set to "reqstool". |
| 27 | + """ |
| 28 | + |
| 29 | + PLUGIN_NAME: str = "reqstool" |
| 30 | + |
| 31 | + CONFIG_SOURCES = "sources" |
| 32 | + CONFIG_DATASET_DIRECTORY = "dataset_directory" |
| 33 | + CONFIG_OUTPUT_DIRECTORY = "output_directory" |
| 34 | + CONFIG_TEST_RESULTS: str = "test_results" |
| 35 | + |
| 36 | + INPUT_FILE_REQUIREMENTS_YML: str = "requirements.yml" |
| 37 | + INPUT_FILE_SOFTWARE_VERIFICATION_CASES_YML: str = "software_verification_cases.yml" |
| 38 | + INPUT_FILE_MANUAL_VERIFICATION_RESULTS_YML: str = "manual_verification_results.yml" |
| 39 | + INPUT_FILE_JUNIT_XML: str = "build/junit.xml" |
| 40 | + INPUT_FILE_ANNOTATIONS_YML: str = "annotations.yml" |
| 41 | + INPUT_DIR_DATASET: str = "reqstool" |
| 42 | + |
| 43 | + OUTPUT_DIR_REQSTOOL: str = "build/reqstool" |
| 44 | + OUTPUT_SDIST_REQSTOOL_YML: str = "reqstool_config.yml" |
| 45 | + |
| 46 | + ARCHIVE_OUTPUT_DIR_TEST_RESULTS: str = "test_results" |
| 47 | + |
| 48 | + YAML_LANGUAGE_SERVER = "# yaml-language-server: $schema=https://raw.githubusercontent.com/Luftfartsverket/reqstool-client/main/src/reqstool/resources/schemas/v1/reqstool_index.schema.json\n" # noqa: E501 |
| 49 | + |
| 50 | + def __init__(self, *args: Any, **kwargs: Any) -> None: |
| 51 | + super().__init__(*args, **kwargs) |
| 52 | + self.__config_path: Optional[str] = None |
| 53 | + |
| 54 | + def initialize(self, version: str, build_data: Dict[str, Any]) -> None: |
| 55 | + """ |
| 56 | + Executes custom actions during the build process. |
| 57 | +
|
| 58 | + Args: |
| 59 | + version (str): The version of the project. |
| 60 | + build_data (dict): The build-related data. |
| 61 | + """ |
| 62 | + self.app.display_info(f"[reqstool] plugin {ReqstoolBuildHook.get_version()} loaded") |
| 63 | + |
| 64 | + self._create_annotations_file() |
| 65 | + |
| 66 | + def finalize(self, version: str, build_data: dict[str, Any], artifact_path: str) -> None: |
| 67 | + |
| 68 | + if artifact_path.endswith(".tar.gz"): |
| 69 | + self._append_to_sdist_tar_gz(version=version, build_data=build_data, artifact_path=artifact_path) |
| 70 | + |
| 71 | + def _create_annotations_file(self) -> None: |
| 72 | + """ |
| 73 | + Generates the annotations.yml file by processing the reqstool decorators. |
| 74 | + """ |
| 75 | + self.app.display_debug("[reqstool] parsing reqstool decorators") |
| 76 | + sources = self.config.get(self.CONFIG_SOURCES, []) |
| 77 | + |
| 78 | + reqstool_output_directory: Path = Path(self.config.get(self.CONFIG_OUTPUT_DIRECTORY, self.OUTPUT_DIR_REQSTOOL)) |
| 79 | + annotations_file: Path = Path(reqstool_output_directory, self.INPUT_FILE_ANNOTATIONS_YML) |
| 80 | + |
| 81 | + decorator_processor = DecoratorProcessor() |
| 82 | + decorator_processor.process_decorated_data(path_to_python_files=sources, output_file=annotations_file) |
| 83 | + |
| 84 | + self.app.display_debug(f"[reqstool] generated {str(annotations_file)}") |
| 85 | + |
| 86 | + def _append_to_sdist_tar_gz(self, version: str, build_data: Dict[str, Any], artifact_path: str) -> None: |
| 87 | + """ |
| 88 | + Appends to sdist containing the annotations file and other necessary data. |
| 89 | + """ |
| 90 | + dataset_directory: Path = Path(self.config.get(self.CONFIG_DATASET_DIRECTORY, self.INPUT_DIR_DATASET)) |
| 91 | + reqstool_output_directory: Path = Path(self.config.get(self.CONFIG_OUTPUT_DIRECTORY, self.OUTPUT_DIR_REQSTOOL)) |
| 92 | + test_result_patterns: List[str] = self.config.get(self.CONFIG_TEST_RESULTS, []) |
| 93 | + requirements_file: Path = Path(dataset_directory, self.INPUT_FILE_REQUIREMENTS_YML) |
| 94 | + svcs_file: Path = Path(dataset_directory, self.INPUT_FILE_SOFTWARE_VERIFICATION_CASES_YML) |
| 95 | + mvrs_file: Path = Path(dataset_directory, self.INPUT_FILE_MANUAL_VERIFICATION_RESULTS_YML) |
| 96 | + annotations_file: Path = Path(reqstool_output_directory, self.INPUT_FILE_ANNOTATIONS_YML) |
| 97 | + |
| 98 | + resources: Dict[str, str] = {} |
| 99 | + |
| 100 | + if not os.path.exists(requirements_file): |
| 101 | + msg: str = f"[reqstool] missing mandatory {self.INPUT_FILE_REQUIREMENTS_YML}: {requirements_file}" |
| 102 | + raise RuntimeError(msg) |
| 103 | + |
| 104 | + resources["requirements"] = str(requirements_file) |
| 105 | + self.app.display_debug(f"[reqstool] added to {self.OUTPUT_SDIST_REQSTOOL_YML}: {requirements_file}") |
| 106 | + |
| 107 | + if os.path.exists(svcs_file): |
| 108 | + resources["software_verification_cases"] = str(svcs_file) |
| 109 | + self.app.display_debug(f"[reqstool] added to {self.OUTPUT_SDIST_REQSTOOL_YML}: {svcs_file}") |
| 110 | + |
| 111 | + if os.path.exists(mvrs_file): |
| 112 | + resources["manual_verification_results"] = str(mvrs_file) |
| 113 | + self.app.display_debug(f"[reqstool] added to {self.OUTPUT_SDIST_REQSTOOL_YML}: {mvrs_file}") |
| 114 | + |
| 115 | + if os.path.exists(annotations_file): |
| 116 | + resources["annotations"] = str(annotations_file) |
| 117 | + self.app.display_debug(f"[reqstool] added to {self.OUTPUT_SDIST_REQSTOOL_YML}: {annotations_file}") |
| 118 | + |
| 119 | + if test_result_patterns: |
| 120 | + resources["test_results"] = str(test_result_patterns) |
| 121 | + self.app.display_debug( |
| 122 | + f"[reqstool] added test_results to {self.OUTPUT_SDIST_REQSTOOL_YML}: {test_result_patterns}" |
| 123 | + ) |
| 124 | + |
| 125 | + reqstool_yaml_data = {"language": "python", "build": "hatch", "resources": resources} |
| 126 | + |
| 127 | + yaml = YAML() |
| 128 | + yaml.default_flow_style = False |
| 129 | + reqstool_yml_io = io.BytesIO() |
| 130 | + reqstool_yml_io.write(f"{self.YAML_LANGUAGE_SERVER}\n".encode("utf-8")) |
| 131 | + reqstool_yml_io.write(f"# version: {self.metadata.version}\n".encode("utf-8")) |
| 132 | + |
| 133 | + self.app.display_debug(f"[reqstool] reqstool config {reqstool_yaml_data}") |
| 134 | + |
| 135 | + yaml.dump(reqstool_yaml_data, reqstool_yml_io) |
| 136 | + reqstool_yml_io.seek(0) |
| 137 | + |
| 138 | + # Path to the existing tar.gz file (constructed from metadata) |
| 139 | + original_tar_gz_file = os.path.join( |
| 140 | + self.directory, |
| 141 | + f"{BuilderInterface.normalize_file_name_component(self.metadata.core.raw_name)}" |
| 142 | + f"-{self.metadata.version}.tar.gz", |
| 143 | + ) |
| 144 | + |
| 145 | + self.app.display_debug(f"[reqstool] tarball: {original_tar_gz_file}") |
| 146 | + |
| 147 | + # Step 1: Extract the original tar.gz file to a temporary directory |
| 148 | + with tempfile.NamedTemporaryFile(delete=True) as temp_tar_file: |
| 149 | + |
| 150 | + temp_tar_file = temp_tar_file.name # Get the name of the temporary file |
| 151 | + |
| 152 | + self.app.display_debug(f"[reqstool] temporary tar file: {temp_tar_file}") |
| 153 | + |
| 154 | + # Extract the original tar.gz file |
| 155 | + with gzip.open(original_tar_gz_file, "rb") as f_in, open(temp_tar_file, "wb") as f_out: |
| 156 | + f_out.write(f_in.read()) |
| 157 | + |
| 158 | + # Step 2: Open the extracted tar file and append the new file |
| 159 | + with tarfile.open(temp_tar_file, "a") as archive: |
| 160 | + file_info = tarfile.TarInfo( |
| 161 | + name=f"{BuilderInterface.normalize_file_name_component(self.metadata.core.raw_name)}-" |
| 162 | + f"{self.metadata.version}/{self.OUTPUT_SDIST_REQSTOOL_YML}" |
| 163 | + ) |
| 164 | + file_info.size = reqstool_yml_io.getbuffer().nbytes |
| 165 | + archive.addfile(tarinfo=file_info, fileobj=reqstool_yml_io) |
| 166 | + |
| 167 | + # Step 3: Recompress the updated tar file back into the original .tar.gz format |
| 168 | + with open(temp_tar_file, "rb") as f_in, gzip.open(original_tar_gz_file, "wb") as f_out: |
| 169 | + f_out.writelines(f_in) |
| 170 | + |
| 171 | + dist_dir: Path = Path(self.directory) |
| 172 | + self.app.display_info( |
| 173 | + f"[reqstool] added {self.OUTPUT_SDIST_REQSTOOL_YML} to " |
| 174 | + f"{os.path.relpath(original_tar_gz_file, dist_dir.parent)}" |
| 175 | + ) |
| 176 | + |
| 177 | + def get_version() -> str: |
| 178 | + try: |
| 179 | + ver: str = f"{version('reqstool-python-hatch-plugin')}" |
| 180 | + except PackageNotFoundError: |
| 181 | + ver: str = "package-not-found" |
| 182 | + |
| 183 | + return ver |
0 commit comments