From 871d2dcb31d7736bebbf9367fedc187e381cd218 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sat, 28 Feb 2026 15:15:06 +0100 Subject: [PATCH 1/5] 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 - Bump minimum Python requirement to >=3.13; update black target to py313 - Add .python-version pinned to 3.13.11 via pyenv - Create missing src/reqstool_python_decorators/decorators/__init__.py - Fix broken import paths in test resource files (src.reqstool_decorators → reqstool_python_decorators) - Fix import in test_processors.py (src.reqstool_python_decorators → reqstool_python_decorators) - Fix state accumulation bug: reset req_svc_results at start of process_decorated_data() - Fix fragile AST elementKind extraction: use node.__class__.__name__[:-3] instead of CPython repr parsing - 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 --- .python-version | 1 + pyproject.toml | 4 +- .../decorators/__init__.py | 1 + .../processors/decorator_processor.py | 4 +- .../requirements_decorators.py | 2 +- .../test_decorators/svc_decorators.py | 2 +- .../processors/test_processors.py | 249 +++++++++++++++++- .../reqstool_decorators/test_decorators.py | 59 +++++ 8 files changed, 312 insertions(+), 10 deletions(-) create mode 100644 .python-version create mode 100644 src/reqstool_python_decorators/decorators/__init__.py create mode 100644 tests/unit/reqstool_decorators/test_decorators.py diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c45fe3 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13.11 diff --git a/pyproject.toml b/pyproject.toml index 83060db..0c1ab4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dynamic = ["version"] authors = [{ name = "reqstool" }] description = "Reqstool Python Decorators" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.13" classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", @@ -48,7 +48,7 @@ dependencies = [ [tool.black] line-length = 120 -target-version = ['py310'] +target-version = ['py313'] [tool.flake8] ignore = ["W503"] 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..ea045e0 100644 --- a/src/reqstool_python_decorators/processors/decorator_processor.py +++ b/src/reqstool_python_decorators/processors/decorator_processor.py @@ -100,7 +100,7 @@ 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, } @@ -199,6 +199,8 @@ def process_decorated_data( 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..99b35ee 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,12 +8,40 @@ def process_decorator_instance(): return DecoratorProcessor() +# --------------------------------------------------------------------------- +# find_python_files +# --------------------------------------------------------------------------- + + 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"))] +def test_find_python_files_empty_dir(process_decorator_instance: DecoratorProcessor, tmpdir): + result = process_decorator_instance.find_python_files(tmpdir) + assert result == [] + + +def test_find_python_files_nested(process_decorator_instance: DecoratorProcessor, tmpdir): + sub = tmpdir.mkdir("sub") + sub.join("nested.py").write("content") + result = process_decorator_instance.find_python_files(tmpdir) + assert str(sub.join("nested.py")) in result + + +def test_find_python_files_ignores_non_py(process_decorator_instance: DecoratorProcessor, tmpdir): + tmpdir.join("readme.txt").write("content") + result = process_decorator_instance.find_python_files(tmpdir) + assert result == [] + + +# --------------------------------------------------------------------------- +# get_functions_and_classes +# --------------------------------------------------------------------------- + + 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') @@ -24,6 +52,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, tmpdir): + file_path = str(tmpdir.join("f.py")) + tmpdir.join("f.py").write('@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, tmpdir): + file_path = str(tmpdir.join("af.py")) + tmpdir.join("af.py").write('@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, tmpdir): + file_path = str(tmpdir.join("m.py")) + tmpdir.join("m.py").write('@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, tmpdir, code, expected_kind +): + """Fix 4: elementKind must be derived from __class__.__name__, not CPython repr.""" + f = tmpdir.join("f.py") + f.write(code) + process_decorator_instance.get_functions_and_classes(str(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, tmpdir): + file_path = str(tmpdir.join("n.py")) + tmpdir.join("n.py").write('@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, tmpdir +): + file_path = str(tmpdir.join("md.py")) + code = '@Requirements("REQ_001")\n@SVCs("SVC_001")\ndef func():\n pass' + tmpdir.join("md.py").write(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 +135,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, tmpdir): + 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 +223,82 @@ 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 = str(tmp_path / "out" / "annotations.yml") + process_decorator_instance.process_decorated_data( + path_to_python_files=[str(src_file.parent)], output_file=output_file + ) + + import os + + assert os.path.exists(output_file) + + +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 = str(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 = str(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 6f211f7820d2cec45b2b430dc4db4002ce57667e Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sat, 28 Feb 2026 15:21:55 +0100 Subject: [PATCH 2/5] docs: add Python requirements section and fix import examples in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Requirements section noting Python >= 3.13 - Fix broken decorator import path (reqstool-decorators → reqstool_python_decorators) - Fix broken processor import path (reqstool.processors → reqstool_python_decorators.processors) --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index af01ce6..b45d0b6 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ This provides decorators and collecting of decorated code, formatting it and writing to yaml file. +## Requirements + +- Python >= 3.13 + ## Installation The package name is `reqstool-python-decorators`. @@ -45,7 +49,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 +69,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: @@ -81,4 +85,4 @@ process_decorated_data(path_to_python_files, output_file) ## License -This project is licensed under the MIT License - see the LICENSE.md file for details. +This project is licensed under the MIT. \ No newline at end of file From a3c818cf17932e18d106de78dbd56856b14ca8bc Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sat, 28 Feb 2026 17:27:53 +0100 Subject: [PATCH 3/5] feat: adopt Python 3.13 type system features decorators.py: - Use generic function syntax [T: Callable] (PEP 695) so type checkers preserve the decorated callable's type through Requirements/SVCs decorator_processor.py: - Add DecoratorInfo and ElementResult TypedDict classes with ReadOnly fields (PEP 705, typing.ReadOnly new in 3.13) to express that result dicts are built once and never mutated - Add `type Results = list[ElementResult]` type alias (PEP 695) - Annotate method signatures and internal variables with these types --- .../decorators/decorators.py | 14 ++++++---- .../processors/decorator_processor.py | 28 +++++++++++++++---- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/reqstool_python_decorators/decorators/decorators.py b/src/reqstool_python_decorators/decorators/decorators.py index 945aa09..7a9ce0c 100644 --- a/src/reqstool_python_decorators/decorators/decorators.py +++ b/src/reqstool_python_decorators/decorators/decorators.py @@ -1,17 +1,19 @@ # Copyright © LFV +from collections.abc import Callable -def Requirements(*requirements): - def decorator(func): - func.requirements = requirements + +def Requirements[T: Callable](*requirements: str) -> Callable[[T], T]: + def decorator(func: T) -> T: + func.requirements = requirements # type: ignore[attr-defined] return func return decorator -def SVCs(*svc_ids): - def decorator(func): - func.svc_ids = svc_ids +def SVCs[T: Callable](*svc_ids: str) -> Callable[[T], T]: + def decorator(func: T) -> T: + func.svc_ids = svc_ids # type: ignore[attr-defined] return func return decorator diff --git a/src/reqstool_python_decorators/processors/decorator_processor.py b/src/reqstool_python_decorators/processors/decorator_processor.py index ea045e0..cb70e3e 100644 --- a/src/reqstool_python_decorators/processors/decorator_processor.py +++ b/src/reqstool_python_decorators/processors/decorator_processor.py @@ -2,6 +2,7 @@ from enum import Enum, unique import os +from typing import ReadOnly, TypedDict from ruamel.yaml import YAML import ast @@ -19,6 +20,21 @@ def get_from_to(self): return f"from: {self.from_value}, to: {self.to_value}" +class DecoratorInfo(TypedDict): + name: ReadOnly[str] + args: ReadOnly[list[str]] + + +class ElementResult(TypedDict): + fullyQualifiedName: ReadOnly[str] + elementKind: ReadOnly[str] + name: ReadOnly[str] + decorators: ReadOnly[list[DecoratorInfo]] + + +type Results = list[ElementResult] + + class DecoratorProcessor: """ A class for collecting and processing Requirements and SVCs annotations on functions and classes in a directory. @@ -47,9 +63,9 @@ def __init__(self, *args, **kwargs): """ super().__init__(*args, **kwargs) - self.req_svc_results = [] + self.req_svc_results: Results = [] - def find_python_files(self, directory): + def find_python_files(self, directory) -> list[str]: """ Find Python files in the given directory. @@ -66,7 +82,7 @@ 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, decorator_names) -> 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. @@ -89,7 +105,7 @@ def get_functions_and_classes(self, file_path, decorator_names): for node in ast.walk(tree): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): - decorators_info = [] + decorators_info: list[DecoratorInfo] = [] for decorator_node in getattr(node, "decorator_list", []): if isinstance(decorator_node, ast.Call) and isinstance(decorator_node.func, ast.Name): decorator_name = decorator_node.func.id @@ -106,7 +122,7 @@ def get_functions_and_classes(self, file_path, decorator_names): } ) - def write_to_yaml(self, output_file, formatted_data): + def write_to_yaml(self, output_file, formatted_data) -> None: """ Write formatted data to a YAML file. @@ -135,7 +151,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: Results) -> dict: """ Format the collected results into a structured data format for YAML. From 82afc712e747c2c650e4b59cef2e8a3eec0eb60c Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sat, 28 Feb 2026 20:44:05 +0100 Subject: [PATCH 4/5] docs: restore full MIT License text and trailing newline in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b45d0b6..2bae351 100644 --- a/README.md +++ b/README.md @@ -85,4 +85,4 @@ process_decorated_data(path_to_python_files, output_file) ## License -This project is licensed under the MIT. \ No newline at end of file +This project is licensed under the MIT License. From f71b5aa3309d201df3ff1a4a6030dbcb2bc3c2b7 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sat, 28 Feb 2026 20:51:12 +0100 Subject: [PATCH 5/5] refactor: address code review findings decorator_processor.py: - Add FormattedEntry, RequirementAnnotations, FormattedData TypedDicts for format_results return type (replaces bare dict) - Add str | os.PathLike to all path/file parameters for consistency - Tighten format_results body to use typed local variables test_processors.py: - Standardise all tests on tmp_path (drop legacy tmpdir fixture) - Remove stray inline `import os` from test body; use pathlib Path.exists() - Remove unused tmpdir parameter from test_format_results_implementations --- .../processors/decorator_processor.py | 59 ++++++++----- .../processors/test_processors.py | 85 +++++++++---------- 2 files changed, 79 insertions(+), 65 deletions(-) diff --git a/src/reqstool_python_decorators/processors/decorator_processor.py b/src/reqstool_python_decorators/processors/decorator_processor.py index cb70e3e..d860d4b 100644 --- a/src/reqstool_python_decorators/processors/decorator_processor.py +++ b/src/reqstool_python_decorators/processors/decorator_processor.py @@ -32,6 +32,20 @@ class ElementResult(TypedDict): decorators: ReadOnly[list[DecoratorInfo]] +class FormattedEntry(TypedDict): + elementKind: str + fullyQualifiedName: str + + +class RequirementAnnotations(TypedDict): + implementations: dict[str, list[FormattedEntry]] + tests: dict[str, list[FormattedEntry]] + + +class FormattedData(TypedDict): + requirement_annotations: RequirementAnnotations + + type Results = list[ElementResult] @@ -65,12 +79,12 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.req_svc_results: Results = [] - def find_python_files(self, directory) -> list[str]: + 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. @@ -82,17 +96,16 @@ def find_python_files(self, directory) -> list[str]: python_files.append(os.path.join(root, file)) return python_files - def get_functions_and_classes(self, file_path, decorator_names) -> 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. 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. @@ -101,7 +114,7 @@ def get_functions_and_classes(self, file_path, decorator_names) -> None: - `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)): @@ -122,12 +135,12 @@ def get_functions_and_classes(self, file_path, decorator_names) -> None: } ) - def write_to_yaml(self, output_file, formatted_data) -> None: + 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. @@ -151,7 +164,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: Results) -> dict: + def format_results(self, results: Results) -> FormattedData: """ Format the collected results into a structured data format for YAML. @@ -159,17 +172,17 @@ def format_results(self, results: Results) -> dict: - `results` (list): List of dictionaries containing information about functions and classes. Returns: - - `formatted_data` (dict): Formatted data in a structured `yaml_language_server` compatible format. + - `formatted_data` (FormattedData): Formatted data in a structured `yaml_language_server` compatible format. This function formats a list of decorated data into the structure required by the `yaml_language_server`. It includes version information, requirement annotations, and relevant element information. """ - formatted_data = {} - implementations = {} - tests = {} - requirement_annotations = {"implementations": implementations, "tests": tests} - formatted_data["requirement_annotations"] = requirement_annotations + implementations: dict[str, list[FormattedEntry]] = {} + tests: dict[str, list[FormattedEntry]] = {} + formatted_data: FormattedData = { + "requirement_annotations": {"implementations": implementations, "tests": tests} + } for result in results: for decorator_info in result["decorators"]: @@ -190,26 +203,28 @@ def format_results(self, results: Results) -> dict: 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. diff --git a/tests/unit/reqstool_decorators/processors/test_processors.py b/tests/unit/reqstool_decorators/processors/test_processors.py index 99b35ee..1f52b82 100644 --- a/tests/unit/reqstool_decorators/processors/test_processors.py +++ b/tests/unit/reqstool_decorators/processors/test_processors.py @@ -13,27 +13,28 @@ def process_decorator_instance(): # --------------------------------------------------------------------------- -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"))] +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, tmpdir): - result = process_decorator_instance.find_python_files(tmpdir) +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, tmpdir): - sub = tmpdir.mkdir("sub") - sub.join("nested.py").write("content") - result = process_decorator_instance.find_python_files(tmpdir) - assert str(sub.join("nested.py")) in 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, tmpdir): - tmpdir.join("readme.txt").write("content") - result = process_decorator_instance.find_python_files(tmpdir) +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 == [] @@ -42,9 +43,9 @@ def test_find_python_files_ignores_non_py(process_decorator_instance: DecoratorP # --------------------------------------------------------------------------- -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_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" @@ -52,9 +53,9 @@ 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, tmpdir): - file_path = str(tmpdir.join("f.py")) - tmpdir.join("f.py").write('@Requirements("REQ_001")\ndef my_func():\n pass') +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] @@ -62,9 +63,9 @@ def test_get_functions_and_classes_function_def(process_decorator_instance: Deco assert result["elementKind"] == "FUNCTION" -def test_get_functions_and_classes_async_function_def(process_decorator_instance: DecoratorProcessor, tmpdir): - file_path = str(tmpdir.join("af.py")) - tmpdir.join("af.py").write('@Requirements("REQ_001")\nasync def my_async():\n pass') +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] @@ -72,9 +73,9 @@ def test_get_functions_and_classes_async_function_def(process_decorator_instance assert result["elementKind"] == "ASYNCFUNCTION" -def test_get_functions_and_classes_multiple_args(process_decorator_instance: DecoratorProcessor, tmpdir): - file_path = str(tmpdir.join("m.py")) - tmpdir.join("m.py").write('@Requirements("A", "B")\ndef func():\n pass') +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"] @@ -89,28 +90,28 @@ def test_get_functions_and_classes_multiple_args(process_decorator_instance: Dec ], ) def test_get_functions_and_classes_element_kind( - process_decorator_instance: DecoratorProcessor, tmpdir, code, expected_kind + process_decorator_instance: DecoratorProcessor, tmp_path, code, expected_kind ): """Fix 4: elementKind must be derived from __class__.__name__, not CPython repr.""" - f = tmpdir.join("f.py") - f.write(code) - process_decorator_instance.get_functions_and_classes(str(f), ["Requirements"]) + 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, tmpdir): - file_path = str(tmpdir.join("n.py")) - tmpdir.join("n.py").write('@OtherDecorator("X")\ndef func():\n pass') +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, tmpdir + process_decorator_instance: DecoratorProcessor, tmp_path ): - file_path = str(tmpdir.join("md.py")) + file_path = tmp_path / "md.py" code = '@Requirements("REQ_001")\n@SVCs("SVC_001")\ndef func():\n pass' - tmpdir.join("md.py").write(code) + 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"]] @@ -140,7 +141,7 @@ def test_map_type_unknown_type(process_decorator_instance: DecoratorProcessor): # --------------------------------------------------------------------------- -def test_format_results_implementations(process_decorator_instance: DecoratorProcessor, tmpdir): +def test_format_results_implementations(process_decorator_instance: DecoratorProcessor): results = [ { "fullyQualifiedName": "my.module.py", @@ -252,14 +253,12 @@ def test_process_decorated_data_produces_yaml(process_decorator_instance: Decora src_file.parent.mkdir() src_file.write_text('@Requirements("REQ_001")\ndef my_func():\n pass\n') - output_file = str(tmp_path / "out" / "annotations.yml") + 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 ) - import os - - assert os.path.exists(output_file) + assert output_file.exists() def test_process_decorated_data_correct_structure(process_decorator_instance: DecoratorProcessor, tmp_path): @@ -267,7 +266,7 @@ def test_process_decorated_data_correct_structure(process_decorator_instance: De src_file.parent.mkdir() src_file.write_text('@Requirements("REQ_001")\ndef my_func():\n pass\n') - output_file = str(tmp_path / "out" / "annotations.yml") + 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 ) @@ -285,7 +284,7 @@ def test_process_decorated_data_no_state_accumulation(process_decorator_instance src_file.parent.mkdir() src_file.write_text('@Requirements("REQ_001")\ndef my_func():\n pass\n') - output_file = str(tmp_path / "out" / "annotations.yml") + output_file = tmp_path / "out" / "annotations.yml" # Call twice process_decorator_instance.process_decorated_data(