From 540504fc3dff03c0ba0e6d9fa6626f1d1644a10c Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sat, 28 Feb 2026 21:27:32 +0100 Subject: [PATCH 1/2] fix: comprehensive tests and bug fixes for decorators and processor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create missing src/reqstool_python_decorators/decorators/__init__.py - Fix broken import paths in test resources and README (src.reqstool_decorators → reqstool_python_decorators) - Fix state accumulation bug: reset req_svc_results at start of process_decorated_data() to prevent results doubling on repeated calls - Fix fragile AST elementKind extraction: replace CPython repr parsing str(type(node)).split(".")[-1][:-5] with node.__class__.__name__[:-3] - Add str | os.PathLike to all path/file parameters - Add test_decorators.py with 7 tests covering Requirements/SVCs decorators - Expand test_processors.py from 5 to 32 tests covering all previously untested methods; standardise fixtures on tmp_path --- README.md | 4 +- .../decorators/__init__.py | 1 + .../processors/decorator_processor.py | 37 +-- .../requirements_decorators.py | 2 +- .../test_decorators/svc_decorators.py | 2 +- .../processors/test_processors.py | 262 +++++++++++++++++- .../reqstool_decorators/test_decorators.py | 59 ++++ 7 files changed, 334 insertions(+), 33 deletions(-) create mode 100644 src/reqstool_python_decorators/decorators/__init__.py create mode 100644 tests/unit/reqstool_decorators/test_decorators.py diff --git a/README.md b/README.md index af01ce6..309cf19 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ reqstool-python-decorators = "" Import decorators: ``` -from reqstool-decorators.decorators.decorators import Requirements, SVCs +from reqstool_python_decorators.decorators.decorators import Requirements, SVCs ``` Example usage of the decorators: @@ -65,7 +65,7 @@ def test_somefunction(): Import processor: ``` -from reqstool.processors.decorator_processor import DecoratorProcessor +from reqstool_python_decorators.processors.decorator_processor import DecoratorProcessor ``` Main function to collect decorators data and generate yaml file: diff --git a/src/reqstool_python_decorators/decorators/__init__.py b/src/reqstool_python_decorators/decorators/__init__.py new file mode 100644 index 0000000..051704b --- /dev/null +++ b/src/reqstool_python_decorators/decorators/__init__.py @@ -0,0 +1 @@ +# Copyright © LFV diff --git a/src/reqstool_python_decorators/processors/decorator_processor.py b/src/reqstool_python_decorators/processors/decorator_processor.py index 6c67332..a1b669e 100644 --- a/src/reqstool_python_decorators/processors/decorator_processor.py +++ b/src/reqstool_python_decorators/processors/decorator_processor.py @@ -49,12 +49,12 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.req_svc_results = [] - def find_python_files(self, directory): + def find_python_files(self, directory: str | os.PathLike) -> list[str]: """ Find Python files in the given directory. Parameters: - - `directory` (str): The directory to search for Python files. + - `directory` (str | PathLike): The directory to search for Python files. Returns: - `python_files` (list): List of Python files found in the directory. @@ -66,17 +66,16 @@ def find_python_files(self, directory): python_files.append(os.path.join(root, file)) return python_files - def get_functions_and_classes(self, file_path, decorator_names): + def get_functions_and_classes( + self, file_path: str | os.PathLike, decorator_names: list[str] + ) -> None: """ Get information about functions and classes, if annotated with "Requirements" or "SVCs": decorator filepath, elementKind, name and decorators is saved to list that is returned. Parameters: - - `file_path` (str): The path to the Python file. - - `decorator_names` (list): List of decorator names to search for. - - Returns: - - `results` (list): List of dictionaries containing information about functions and classes. + - `file_path` (str | PathLike): The path to the Python file. + - `decorator_names` (list[str]): List of decorator names to search for. Each dictionary includes: - `fullyQualifiedName` (str): The fully qualified name of the file. @@ -85,7 +84,7 @@ def get_functions_and_classes(self, file_path, decorator_names): - `decorators` (list): List of dictionaries with decorator info including name and arguments e.g. "REQ_001". """ with open(file_path, "r") as file: - tree = ast.parse(file.read(), filename=file_path) + tree = ast.parse(file.read(), filename=str(file_path)) for node in ast.walk(tree): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): @@ -100,18 +99,18 @@ def get_functions_and_classes(self, file_path, decorator_names): self.req_svc_results.append( { "fullyQualifiedName": str(file_path).replace("/", "."), - "elementKind": str(type(node)).split(".")[-1][:-5].upper(), + "elementKind": node.__class__.__name__[:-3].upper(), "name": node.name, "decorators": decorators_info, } ) - def write_to_yaml(self, output_file, formatted_data): + def write_to_yaml(self, output_file: str | os.PathLike, formatted_data) -> None: """ Write formatted data to a YAML file. Parameters: - - `output_file` (str): The path to the output YAML file. + - `output_file` (str | PathLike): The path to the output YAML file. - `formatted_data` (dict): The formatted data to be written to the YAML file. Writes the formatted data to the specified YAML file. @@ -135,7 +134,7 @@ def map_type(self, input_str) -> str: mapping = {item.from_value: item.to_value for item in DECORATOR_TYPES} return mapping.get(input_str, input_str) - def format_results(self, results): + def format_results(self, results) -> dict: """ Format the collected results into a structured data format for YAML. @@ -174,31 +173,35 @@ def format_results(self, results): return formatted_data - def create_dir_from_path(self, filepath: str) -> None: + def create_dir_from_path(self, filepath: str | os.PathLike) -> None: """ Creates directory of provided filepath if it does not exists Parameters: - - `filepath` (str): Filepath to check and create directory from. + - `filepath` (str | PathLike): Filepath to check and create directory from. """ directory = os.path.dirname(filepath) if not os.path.exists(directory): os.makedirs(directory) def process_decorated_data( - self, path_to_python_files: str, output_file: str = "build/reqstool/annotations.yml" + self, + path_to_python_files: list[str | os.PathLike], + output_file: str | os.PathLike = "build/reqstool/annotations.yml", ) -> None: """ "Main" function, runs all functions resulting in a yaml file containing decorated data. Parameters: - `path_to_python_files` (list): List of directories containing Python files. - - `output_file` (str): Set path for output file, defaults to build/annotations.yml + - `output_file` (str | PathLike): Set path for output file, defaults to build/annotations.yml This method takes a list of directories containing Python files, collects decorated data from these files, formats the collected data, and writes the formatted results to YAML file for Requirements and SVCs annotations. """ + self.req_svc_results = [] + for path in path_to_python_files: python_files = self.find_python_files(directory=path) for file_path in python_files: diff --git a/tests/resources/test_decorators/requirements_decorators.py b/tests/resources/test_decorators/requirements_decorators.py index a7c1b4a..413deb0 100644 --- a/tests/resources/test_decorators/requirements_decorators.py +++ b/tests/resources/test_decorators/requirements_decorators.py @@ -1,4 +1,4 @@ -from src.reqstool_decorators.decorators.decorators import Requirements +from reqstool_python_decorators.decorators.decorators import Requirements @Requirements("REQ_001", "REQ_222") diff --git a/tests/resources/test_decorators/svc_decorators.py b/tests/resources/test_decorators/svc_decorators.py index c13e762..b6531a9 100644 --- a/tests/resources/test_decorators/svc_decorators.py +++ b/tests/resources/test_decorators/svc_decorators.py @@ -1,4 +1,4 @@ -from src.reqstool_decorators.decorators.decorators import SVCs +from reqstool_python_decorators.decorators.decorators import SVCs @SVCs("SVC_999") diff --git a/tests/unit/reqstool_decorators/processors/test_processors.py b/tests/unit/reqstool_decorators/processors/test_processors.py index fa74a59..1f52b82 100644 --- a/tests/unit/reqstool_decorators/processors/test_processors.py +++ b/tests/unit/reqstool_decorators/processors/test_processors.py @@ -1,5 +1,5 @@ import pytest -from src.reqstool_python_decorators.processors.decorator_processor import DecoratorProcessor +from reqstool_python_decorators.processors.decorator_processor import DecoratorProcessor from ruamel.yaml import YAML @@ -8,15 +8,44 @@ def process_decorator_instance(): return DecoratorProcessor() -def test_find_python_files(process_decorator_instance: DecoratorProcessor, tmpdir): - tmpdir.join("pythonfile.py").write("content") - result = process_decorator_instance.find_python_files(tmpdir) - assert result == [str(tmpdir.join("pythonfile.py"))] +# --------------------------------------------------------------------------- +# find_python_files +# --------------------------------------------------------------------------- -def test_get_functions_and_classes(process_decorator_instance: DecoratorProcessor, tmpdir): - file_path = str(tmpdir.join("test_file.py")) - tmpdir.join("test_file.py").write('@SVCs("SVC_001")\nclass Test:\n pass') +def test_find_python_files(process_decorator_instance: DecoratorProcessor, tmp_path): + (tmp_path / "pythonfile.py").write_text("content") + result = process_decorator_instance.find_python_files(tmp_path) + assert result == [str(tmp_path / "pythonfile.py")] + + +def test_find_python_files_empty_dir(process_decorator_instance: DecoratorProcessor, tmp_path): + result = process_decorator_instance.find_python_files(tmp_path) + assert result == [] + + +def test_find_python_files_nested(process_decorator_instance: DecoratorProcessor, tmp_path): + sub = tmp_path / "sub" + sub.mkdir() + (sub / "nested.py").write_text("content") + result = process_decorator_instance.find_python_files(tmp_path) + assert str(sub / "nested.py") in result + + +def test_find_python_files_ignores_non_py(process_decorator_instance: DecoratorProcessor, tmp_path): + (tmp_path / "readme.txt").write_text("content") + result = process_decorator_instance.find_python_files(tmp_path) + assert result == [] + + +# --------------------------------------------------------------------------- +# get_functions_and_classes +# --------------------------------------------------------------------------- + + +def test_get_functions_and_classes(process_decorator_instance: DecoratorProcessor, tmp_path): + file_path = tmp_path / "test_file.py" + file_path.write_text('@SVCs("SVC_001")\nclass Test:\n pass') process_decorator_instance.get_functions_and_classes(file_path, ["SVCs"]) assert process_decorator_instance.req_svc_results[0]["name"] == "Test" assert process_decorator_instance.req_svc_results[0]["decorators"][0]["args"][0] == "SVC_001" @@ -24,6 +53,77 @@ def test_get_functions_and_classes(process_decorator_instance: DecoratorProcesso assert process_decorator_instance.req_svc_results[0]["elementKind"] == "CLASS" +def test_get_functions_and_classes_function_def(process_decorator_instance: DecoratorProcessor, tmp_path): + file_path = tmp_path / "f.py" + file_path.write_text('@Requirements("REQ_001")\ndef my_func():\n pass') + process_decorator_instance.get_functions_and_classes(file_path, ["Requirements"]) + assert len(process_decorator_instance.req_svc_results) == 1 + result = process_decorator_instance.req_svc_results[0] + assert result["name"] == "my_func" + assert result["elementKind"] == "FUNCTION" + + +def test_get_functions_and_classes_async_function_def(process_decorator_instance: DecoratorProcessor, tmp_path): + file_path = tmp_path / "af.py" + file_path.write_text('@Requirements("REQ_001")\nasync def my_async():\n pass') + process_decorator_instance.get_functions_and_classes(file_path, ["Requirements"]) + assert len(process_decorator_instance.req_svc_results) == 1 + result = process_decorator_instance.req_svc_results[0] + assert result["name"] == "my_async" + assert result["elementKind"] == "ASYNCFUNCTION" + + +def test_get_functions_and_classes_multiple_args(process_decorator_instance: DecoratorProcessor, tmp_path): + file_path = tmp_path / "m.py" + file_path.write_text('@Requirements("A", "B")\ndef func():\n pass') + process_decorator_instance.get_functions_and_classes(file_path, ["Requirements"]) + args = process_decorator_instance.req_svc_results[0]["decorators"][0]["args"] + assert args == ["A", "B"] + + +@pytest.mark.parametrize( + "code,expected_kind", + [ + ('@Requirements("REQ_001")\ndef func(): pass', "FUNCTION"), + ('@Requirements("REQ_001")\nasync def func(): pass', "ASYNCFUNCTION"), + ('@Requirements("REQ_001")\nclass MyClass: pass', "CLASS"), + ], +) +def test_get_functions_and_classes_element_kind( + process_decorator_instance: DecoratorProcessor, tmp_path, code, expected_kind +): + """Fix 4: elementKind must be derived from __class__.__name__, not CPython repr.""" + f = tmp_path / "f.py" + f.write_text(code) + process_decorator_instance.get_functions_and_classes(f, ["Requirements"]) + assert process_decorator_instance.req_svc_results[0]["elementKind"] == expected_kind + + +def test_get_functions_and_classes_no_match(process_decorator_instance: DecoratorProcessor, tmp_path): + file_path = tmp_path / "n.py" + file_path.write_text('@OtherDecorator("X")\ndef func():\n pass') + process_decorator_instance.get_functions_and_classes(file_path, ["Requirements"]) + assert process_decorator_instance.req_svc_results == [] + + +def test_get_functions_and_classes_multiple_decorators_on_func( + process_decorator_instance: DecoratorProcessor, tmp_path +): + file_path = tmp_path / "md.py" + code = '@Requirements("REQ_001")\n@SVCs("SVC_001")\ndef func():\n pass' + file_path.write_text(code) + process_decorator_instance.get_functions_and_classes(file_path, ["Requirements", "SVCs"]) + assert len(process_decorator_instance.req_svc_results) == 1 + names = [d["name"] for d in process_decorator_instance.req_svc_results[0]["decorators"]] + assert "Requirements" in names + assert "SVCs" in names + + +# --------------------------------------------------------------------------- +# map_type +# --------------------------------------------------------------------------- + + def test_map_type_known_type(process_decorator_instance: DecoratorProcessor): map_funcion = process_decorator_instance.map_type("FUNCTION") map_asyncfunction = process_decorator_instance.map_type("ASYNCFUNCTION") @@ -36,6 +136,71 @@ def test_map_type_unknown_type(process_decorator_instance: DecoratorProcessor): assert result == "CLASS" +# --------------------------------------------------------------------------- +# format_results +# --------------------------------------------------------------------------- + + +def test_format_results_implementations(process_decorator_instance: DecoratorProcessor): + results = [ + { + "fullyQualifiedName": "my.module.py", + "elementKind": "FUNCTION", + "name": "func", + "decorators": [{"name": "Requirements", "args": ["REQ_001"]}], + } + ] + data = process_decorator_instance.format_results(results) + assert "REQ_001" in data["requirement_annotations"]["implementations"] + + +def test_format_results_tests(process_decorator_instance: DecoratorProcessor): + results = [ + { + "fullyQualifiedName": "my.module.py", + "elementKind": "FUNCTION", + "name": "test_func", + "decorators": [{"name": "SVCs", "args": ["SVC_001"]}], + } + ] + data = process_decorator_instance.format_results(results) + assert "SVC_001" in data["requirement_annotations"]["tests"] + + +def test_format_results_multiple_ids(process_decorator_instance: DecoratorProcessor): + results = [ + { + "fullyQualifiedName": "my.module.py", + "elementKind": "FUNCTION", + "name": "func", + "decorators": [{"name": "Requirements", "args": ["REQ_001", "REQ_002"]}], + } + ] + data = process_decorator_instance.format_results(results) + impls = data["requirement_annotations"]["implementations"] + assert "REQ_001" in impls + assert "REQ_002" in impls + + +def test_format_results_fully_qualified_name(process_decorator_instance: DecoratorProcessor): + results = [ + { + "fullyQualifiedName": "path.to.module.py", + "elementKind": "FUNCTION", + "name": "my_func", + "decorators": [{"name": "Requirements", "args": ["REQ_001"]}], + } + ] + data = process_decorator_instance.format_results(results) + entry = data["requirement_annotations"]["implementations"]["REQ_001"][0] + assert entry["fullyQualifiedName"].endswith(".my_func") + + +# --------------------------------------------------------------------------- +# write_to_yaml +# --------------------------------------------------------------------------- + + def test_write_to_yaml(process_decorator_instance: DecoratorProcessor, tmp_path): yaml_language_server = "# yaml-language-server: $schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/annotations.schema.json\n" # noqa: E501 @@ -59,7 +224,80 @@ def test_write_to_yaml(process_decorator_instance: DecoratorProcessor, tmp_path) assert sample_formatted_data == loaded_data -@pytest.mark.skip(reason="Test manually and check structure of the annotations.yml file generated in build folder") -def test_process_decorated_data(process_decorator_instance: DecoratorProcessor): - paths = ["tests"] - process_decorator_instance.process_decorated_data(path_to_python_files=paths) +# --------------------------------------------------------------------------- +# create_dir_from_path +# --------------------------------------------------------------------------- + + +def test_create_dir_from_path_creates_dir(process_decorator_instance: DecoratorProcessor, tmp_path): + new_dir = tmp_path / "new_subdir" + filepath = str(new_dir / "output.yml") + process_decorator_instance.create_dir_from_path(filepath) + assert new_dir.exists() + + +def test_create_dir_from_path_existing_dir(process_decorator_instance: DecoratorProcessor, tmp_path): + filepath = str(tmp_path / "output.yml") + # tmp_path already exists — should not raise + process_decorator_instance.create_dir_from_path(filepath) + assert tmp_path.exists() + + +# --------------------------------------------------------------------------- +# process_decorated_data +# --------------------------------------------------------------------------- + + +def test_process_decorated_data_produces_yaml(process_decorator_instance: DecoratorProcessor, tmp_path): + src_file = tmp_path / "src" / "app.py" + src_file.parent.mkdir() + src_file.write_text('@Requirements("REQ_001")\ndef my_func():\n pass\n') + + output_file = tmp_path / "out" / "annotations.yml" + process_decorator_instance.process_decorated_data( + path_to_python_files=[str(src_file.parent)], output_file=output_file + ) + + assert output_file.exists() + + +def test_process_decorated_data_correct_structure(process_decorator_instance: DecoratorProcessor, tmp_path): + src_file = tmp_path / "src" / "app.py" + src_file.parent.mkdir() + src_file.write_text('@Requirements("REQ_001")\ndef my_func():\n pass\n') + + output_file = tmp_path / "out" / "annotations.yml" + process_decorator_instance.process_decorated_data( + path_to_python_files=[str(src_file.parent)], output_file=output_file + ) + + yaml = YAML() + with open(output_file) as f: + data = yaml.load(f) + + assert "requirement_annotations" in data + assert "REQ_001" in data["requirement_annotations"]["implementations"] + + +def test_process_decorated_data_no_state_accumulation(process_decorator_instance: DecoratorProcessor, tmp_path): + src_file = tmp_path / "src" / "app.py" + src_file.parent.mkdir() + src_file.write_text('@Requirements("REQ_001")\ndef my_func():\n pass\n') + + output_file = tmp_path / "out" / "annotations.yml" + + # Call twice + process_decorator_instance.process_decorated_data( + path_to_python_files=[str(src_file.parent)], output_file=output_file + ) + process_decorator_instance.process_decorated_data( + path_to_python_files=[str(src_file.parent)], output_file=output_file + ) + + yaml = YAML() + with open(output_file) as f: + data = yaml.load(f) + + # Should have exactly 1 entry, not 2 due to state accumulation + entries = data["requirement_annotations"]["implementations"]["REQ_001"] + assert len(entries) == 1 diff --git a/tests/unit/reqstool_decorators/test_decorators.py b/tests/unit/reqstool_decorators/test_decorators.py new file mode 100644 index 0000000..b0432cc --- /dev/null +++ b/tests/unit/reqstool_decorators/test_decorators.py @@ -0,0 +1,59 @@ +# Copyright © LFV + +from reqstool_python_decorators.decorators.decorators import Requirements, SVCs + + +def test_requirements_sets_attribute(): + @Requirements("REQ_001") + def func(): + pass + + assert func.requirements == ("REQ_001",) + + +def test_requirements_multiple_ids(): + @Requirements("A", "B") + def func(): + pass + + assert func.requirements == ("A", "B") + + +def test_requirements_preserves_function_name(): + @Requirements("REQ_001") + def my_function(): + pass + + assert my_function.__name__ == "my_function" + + +def test_svcs_sets_attribute(): + @SVCs("SVC_001") + def func(): + pass + + assert func.svc_ids == ("SVC_001",) + + +def test_svcs_multiple_ids(): + @SVCs("A", "B") + def func(): + pass + + assert func.svc_ids == ("A", "B") + + +def test_svcs_preserves_function_name(): + @SVCs("SVC_001") + def my_function(): + pass + + assert my_function.__name__ == "my_function" + + +def test_requirements_on_class(): + @Requirements("REQ_001") + class MyClass: + pass + + assert MyClass.requirements == ("REQ_001",) From 02f6d49dececa8d9b68125ef64a2b383967e1b96 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sun, 1 Mar 2026 10:26:15 +0100 Subject: [PATCH 2/2] style: apply black formatting to decorator_processor --- .../processors/decorator_processor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/reqstool_python_decorators/processors/decorator_processor.py b/src/reqstool_python_decorators/processors/decorator_processor.py index a1b669e..9f81d21 100644 --- a/src/reqstool_python_decorators/processors/decorator_processor.py +++ b/src/reqstool_python_decorators/processors/decorator_processor.py @@ -66,9 +66,7 @@ def find_python_files(self, directory: str | os.PathLike) -> list[str]: python_files.append(os.path.join(root, file)) return python_files - def get_functions_and_classes( - self, file_path: str | os.PathLike, decorator_names: list[str] - ) -> None: + def get_functions_and_classes(self, file_path: str | os.PathLike, decorator_names: list[str]) -> None: """ Get information about functions and classes, if annotated with "Requirements" or "SVCs": decorator filepath, elementKind, name and decorators is saved to list that is returned.