From 203deca736f21eca0f016c5da738e862d1422865 Mon Sep 17 00:00:00 2001 From: Dmytro Striletskyi Date: Tue, 10 Sep 2024 19:43:11 +0200 Subject: [PATCH] Add intentions JSON file rendering (for UI docs) (#1) --- .gitignore | 2 + .project-version | 2 +- Makefile | 3 +- README.md | 2 + fixtures/test_file.py | 88 +++++++++++++ intentions/__init__.py | 1 + intentions/main.py | 31 +++-- intentions/render/__init__.py | 0 intentions/render/ast_.py | 74 +++++++++++ intentions/render/dto.py | 28 +++++ intentions/render/encoders.py | 14 +++ intentions/render/enums.py | 8 ++ intentions/render/main.py | 125 +++++++++++++++++++ intentions/utils.py | 5 + pyproject.toml | 7 ++ requirements/tests.txt | 1 + setup.cfg | 7 ++ tests/conftest.py | 12 ++ tests/render/__init__.py | 0 tests/render/test_main.py | 229 ++++++++++++++++++++++++++++++++++ 20 files changed, 629 insertions(+), 10 deletions(-) create mode 100644 fixtures/test_file.py create mode 100644 intentions/render/__init__.py create mode 100644 intentions/render/ast_.py create mode 100644 intentions/render/dto.py create mode 100644 intentions/render/encoders.py create mode 100644 intentions/render/enums.py create mode 100644 intentions/render/main.py create mode 100644 intentions/utils.py create mode 100644 requirements/tests.txt create mode 100644 setup.cfg create mode 100644 tests/conftest.py create mode 100644 tests/render/__init__.py create mode 100644 tests/render/test_main.py diff --git a/.gitignore b/.gitignore index 558a220..3bd93b9 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,5 @@ venv.bak/ .idea/ .DS_Store + +.intentions diff --git a/.project-version b/.project-version index bbdeab6..1750564 100644 --- a/.project-version +++ b/.project-version @@ -1 +1 @@ -0.0.5 +0.0.6 diff --git a/Makefile b/Makefile index 173d637..d846e54 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,8 @@ get-project-version: install-requirements: pip3 install \ -r requirements/dev.txt \ - -r requirements/ops.txt + -r requirements/ops.txt \ + -r requirements/tests.txt check-code-quality: isort $(SOURCE_FOLDER) --diff --check-only diff --git a/README.md b/README.md index 14f0665..1b49052 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,8 @@ class TestDocumentVerificationService: user=user, admin=admin, ) + + ... ``` For `expect`, it describes the expected behavior or change in the system. Important to use this construct to emphasize diff --git a/fixtures/test_file.py b/fixtures/test_file.py new file mode 100644 index 0000000..a75cf17 --- /dev/null +++ b/fixtures/test_file.py @@ -0,0 +1,88 @@ +from intentions import ( + case, + describe, + expect, + when, +) + + +@describe(object='Accounts Service', domain='accounts') +class TestAccountsService: + + def test_transfer_money_with_insufficient_balance(self): + with when('Sender account has insufficient balance'): + pass + + with case('Transfer money from one sender to receiver'): + pass + + with expect('No transfers have been made'): + pass + + def test_transfer_money_with_sufficient_balance(self): + with when('Sender account has sufficient balance'): + pass + + with case('Transfer money from one sender to receiver'): + pass + + with expect('Sender account balance decreased on the transfer money amount'): + pass + + +@describe(object='Accounts Service', domain='accounts') +def test_transfer_money_to_non_existing_receiver_account(): + with when('Receiver account does not exist'): + pass + + with case('Transfer money from one sender to receiver'): + pass + + with expect('Receiver account does not exist error is raised'): + pass + + +@describe(object='Investments Service', domain='investments') +class TestInvestmentsService: + + def test_invest_money_into_stocks(self): + with case('Invest money into stocks'): + pass + + with expect('Stock is purchased'): + pass + + def test_invest_money_into_crypto(self): + with case('Invest money into crypto'): + pass + + with expect('Crypto is purchased'): + pass + + +@describe(object='Investments Service', domain='investments') +def test_invest_into_non_existing_stocks(): + with when('Stock to buy does not exist'): + pass + + with case('Invest money into stocks'): + pass + + with expect('Stock does not exist error is raised'): + pass + + +def test_invest_into_non_existing_crypto(): + with when('Crypto to buy does not exist'): + pass + + with case('Invest money into crypto'): + pass + + with expect('Crypto does not exist error is raised'): + pass + + +@describe(object='Investments Service', domain='investments') +def test_sum(): + assert 4 == 2 + 2 diff --git a/intentions/__init__.py b/intentions/__init__.py index ed1456c..70cbaa6 100644 --- a/intentions/__init__.py +++ b/intentions/__init__.py @@ -1,5 +1,6 @@ from intentions.main import ( case, + describe, expect, when, ) diff --git a/intentions/main.py b/intentions/main.py index 1a4a5cc..eb57a91 100644 --- a/intentions/main.py +++ b/intentions/main.py @@ -1,8 +1,17 @@ +from __future__ import annotations + from typing import ( + TYPE_CHECKING, + Any, + Callable, Optional, - Type, + TypeVar, ) -from types import TracebackType + +if TYPE_CHECKING: + from types import TracebackType + +T = TypeVar('T') class AbstractIntention: @@ -19,15 +28,15 @@ def __init__(self, description: str) -> None: """ self.description = description - def __enter__(self) -> 'AbstractIntention': + def __enter__(self) -> AbstractIntention: return self def __exit__( self, - exc_type: Optional[Type[BaseException]], + exc_type: Optional[type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType], - ): + ) -> None: return @@ -35,18 +44,24 @@ class when(AbstractIntention): """ "When" intention implementation. """ - pass class case(AbstractIntention): """ "Case" intention implementation. """ - pass class expect(AbstractIntention): """ "Expect" intention implementation. """ - pass + + +def describe(object: str, domain: str) -> Callable[[Callable[..., T]], Callable[..., T]]: # noqa: ARG001 + def decorator(func: [..., T]) -> Callable[..., T]: + def wrapper(*args: tuple[Any, ...], **kwargs: dict[str, Any]) -> T: + result = func(*args, **kwargs) + return result + return wrapper + return decorator diff --git a/intentions/render/__init__.py b/intentions/render/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/intentions/render/ast_.py b/intentions/render/ast_.py new file mode 100644 index 0000000..0c29c56 --- /dev/null +++ b/intentions/render/ast_.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import ast +from typing import Optional + +from intentions.render.dto import Describe + + +class AstNodeVisitor(ast.NodeVisitor): + """ + AST node visitor implementation. + """ + + def __init__(self) -> None: + """ + Construct the object. + """ + self.parent_map = {} + self.nodes = [] + + def visit(self, node: ast.AST) -> None: + """ + Visit the node. + + It travers the tree in the preorder way and collect parents. Preorder traversal is needed to collect test + functions in the top to down manner with even class's test function be respected. Collecting parents is needed + to have access to class names and description for further rendering. + + Arguments: + node (ast.AST): a node to visit, typically the root node. + """ + self.nodes.append(node) + + for child in ast.iter_child_nodes(node): + if child not in self.parent_map: + self.parent_map[child] = node + + self.generic_visit(node) + + def get_parent(self, node: ast.AST) -> Optional[ast.AST]: + return self.parent_map.get(node, None) + + def get_nodes(self) -> list[ast.AST]: + return self.nodes + + def get_describe(self, decorators: [ast.Call]) -> Optional[Describe]: + """ + Get describe context manager description. + + Arguments: + decorators (list): list of decorators over a function or class that potentially relate to test cases. + + Returns: + An object and domain as `Describe`. + """ + for decorator in decorators: + if not isinstance(decorator, ast.Call): + continue + + if not isinstance(decorator.func, ast.Name): + continue + + if decorator.func.id != 'describe': + continue + + assert decorator.keywords[0].arg == 'object' # noqa: S101 + assert decorator.keywords[1].arg == 'domain' # noqa: S101 + + return Describe( + object=decorator.keywords[0].value.value, + domain=decorator.keywords[1].value.value, + ) + + return None diff --git a/intentions/render/dto.py b/intentions/render/dto.py new file mode 100644 index 0000000..0b1eac0 --- /dev/null +++ b/intentions/render/dto.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass + +from intentions.render.enums import Intention + + +@dataclass +class Describe: + object: str # noqa: A003 + domain: str + + +@dataclass +class TestCaseIntention: + + type: Intention # noqa: A003 + code_line: int + description: str + + +@dataclass +class TestCase: + + file_path: str + class_name: str + class_code_line: int + function_name: str + function_code_line: int + intentions: list[TestCaseIntention] diff --git a/intentions/render/encoders.py b/intentions/render/encoders.py new file mode 100644 index 0000000..63c58c9 --- /dev/null +++ b/intentions/render/encoders.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from enum import Enum +from json import JSONEncoder +from typing import Union + + +class JsonEncoderWithEnumSupport(JSONEncoder): + + def default(self, obj: Union[Enum, object]) -> object: + if isinstance(obj, Enum): + return obj.value + + return super().default(obj) diff --git a/intentions/render/enums.py b/intentions/render/enums.py new file mode 100644 index 0000000..ae57a48 --- /dev/null +++ b/intentions/render/enums.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class Intention(Enum): + + WHEN = 'when' + CASE = 'case' + EXPECT = 'expect' diff --git a/intentions/render/main.py b/intentions/render/main.py new file mode 100644 index 0000000..279e696 --- /dev/null +++ b/intentions/render/main.py @@ -0,0 +1,125 @@ +import ast +import json +from dataclasses import asdict +from pathlib import Path + +from intentions.render.ast_ import AstNodeVisitor +from intentions.render.dto import ( + TestCase, + TestCaseIntention, +) +from intentions.render.encoders import JsonEncoderWithEnumSupport +from intentions.render.enums import Intention +from intentions.utils import is_test_function + + +def collect_test_files(directory: str) -> list[Path]: + test_files = [] + + for path in Path(directory).rglob('test_*.py'): + test_files.append(path) + + return test_files + + +def collect_test_cases(storage: dict, file: Path) -> None: + file_text = file.read_text() + file_as_ast = ast.parse(file_text) + + ast_node_visitor = AstNodeVisitor() + ast_node_visitor.visit(file_as_ast) + + nodes = ast_node_visitor.get_nodes() + + for node in nodes: + if not isinstance(node, ast.FunctionDef): + continue + + function_node = node + + if not is_test_function(name=function_node.name): + continue + + test_case_intentions = [] + + for node in function_node.body: + if not isinstance(node, ast.With): + continue + + with_node = node + + for with_node_item in with_node.items: + if not isinstance(with_node_item.context_expr.func, ast.Name): + continue + + with_node_item_name = with_node_item.context_expr.func.id + + if with_node_item_name not in ('when', 'case', 'expect'): + continue + + with_node_item_code_line = with_node_item.context_expr.args[0].lineno + with_node_item_description = with_node_item.context_expr.args[0].value + + test_sase_intention = TestCaseIntention( + type=Intention(with_node_item_name), + code_line=with_node_item_code_line, + description=with_node_item_description, + ) + + test_case_intentions.append(test_sase_intention) + + if not test_case_intentions: + continue + + class_name = None + class_code_line = None + + parent_node = ast_node_visitor.get_parent(function_node) + + if isinstance(parent_node, ast.ClassDef): + class_name = parent_node.name + class_code_line = parent_node.lineno + + if isinstance(parent_node, ast.ClassDef): + describe = ast_node_visitor.get_describe(decorators=parent_node.decorator_list) + + else: + describe = ast_node_visitor.get_describe(decorators=function_node.decorator_list) + + if describe is None: + continue + + if describe.domain not in storage: + storage[describe.domain] = {} + + if describe.object not in storage[describe.domain]: + storage[describe.domain][describe.object] = [] + + test_function = TestCase( + function_name=function_node.name, + function_code_line=function_node.lineno, + intentions=test_case_intentions, + class_name=class_name, + class_code_line=class_code_line, + file_path=file.as_posix(), + ) + + test_case_as_dict = asdict(test_function) + storage[describe.domain][describe.object].append(test_case_as_dict) + + +def create_intentions_json(directory: str) -> None: + test_files = collect_test_files(directory=directory) + + storage = {} + + for test_file in test_files: + collect_test_cases(storage=storage, file=test_file) + + intentions_folder_path = Path('./.intentions') + + if not intentions_folder_path.exists(): + intentions_folder_path.mkdir(parents=True) + + with open('./.intentions/intentions.json', 'w') as file: + json.dump(storage, file, indent=4, cls=JsonEncoderWithEnumSupport) diff --git a/intentions/utils.py b/intentions/utils.py new file mode 100644 index 0000000..a693963 --- /dev/null +++ b/intentions/utils.py @@ -0,0 +1,5 @@ +def is_test_function(name: str) -> bool: + if name.startswith('test_'): + return True + + return False diff --git a/pyproject.toml b/pyproject.toml index 6694478..6269fbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,8 @@ ignore = [ "UP007", "C901", "PTH109", + "PERF402", + "ANN101", ] [tool.ruff.per-file-ignores] @@ -23,6 +25,11 @@ ignore = [ "D104", "F401", ] +"main.py" = [ + "N801", + "PLR0912", + "PLW2901", +] [tool.ruff.flake8-quotes] docstring-quotes = "double" diff --git a/requirements/tests.txt b/requirements/tests.txt new file mode 100644 index 0000000..fe93bd5 --- /dev/null +++ b/requirements/tests.txt @@ -0,0 +1 @@ +pytest==8.3.2 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2a2ec50 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,7 @@ +[isort] +known_local_folder=threads +line_length=120 +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=True +combine_as_imports=True diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6038e27 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +import os +import pytest + + +@pytest.fixture +def remove_intentions_json(): + intentions_json = './.intentions/intentions.json' + + yield intentions_json + + if os.path.exists(intentions_json): + os.remove(intentions_json) diff --git a/tests/render/__init__.py b/tests/render/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/render/test_main.py b/tests/render/test_main.py new file mode 100644 index 0000000..b805aa4 --- /dev/null +++ b/tests/render/test_main.py @@ -0,0 +1,229 @@ +import json + +from intentions.main import ( + case, + expect, + when, +) +from intentions.render.main import create_intentions_json + + +class TestRender: + + def test_create_intentions_json(self, remove_intentions_json) -> None: + with when('Tests folder with tests using intentions library exists'): + path_to_tests_folder = './fixtures' + + with case('Create intentions JSON file'): + create_intentions_json(directory=path_to_tests_folder) + + with open('./.intentions/intentions.json', 'r') as intentions_json: + intentions_json = json.load(intentions_json) + + with expect('First intentions JSON keys corresponds to domains'): + assert intentions_json['accounts'] is not None + assert intentions_json['investments'] is not None + + with expect('Each intentions JSON domain consist of its objects'): + assert intentions_json['accounts']['Accounts Service'] + assert intentions_json['investments']['Investments Service'] + + def test_create_intentions_json_accounts_service(self, remove_intentions_json) -> None: + with when('Tests folder with tests for accounts service exist'): + path_to_tests_folder = './fixtures' + + with case('Create intentions JSON file'): + create_intentions_json(directory=path_to_tests_folder) + + with open('./.intentions/intentions.json', 'r') as intentions_json: + intentions_json = json.load(intentions_json) + + with expect('Test transfer money with insufficient balance test case is in accounts service test cases'): + expected_test_case = { + 'file_path': 'fixtures/test_file.py', + 'class_name': 'TestAccountsService', + 'class_code_line': 10, + 'function_name': 'test_transfer_money_with_insufficient_balance', + 'function_code_line': 12, + 'intentions': [ + { + 'type': 'when', + 'code_line': 13, + 'description': 'Sender account has insufficient balance', + }, + { + 'type': 'case', + 'code_line': 16, + 'description': 'Transfer money from one sender to receiver', + }, + { + 'type': 'expect', + 'code_line': 19, + 'description': 'No transfers have been made', + } + ] + } + + assert expected_test_case in intentions_json['accounts']['Accounts Service'] + + with expect('Test transfer money with sufficient balance test case is in accounts service test cases'): + expected_test_case = { + 'file_path': 'fixtures/test_file.py', + 'class_name': 'TestAccountsService', + 'class_code_line': 10, + 'function_name': 'test_transfer_money_with_sufficient_balance', + 'function_code_line': 22, + 'intentions': [ + { + 'type': 'when', + 'code_line': 23, + 'description': 'Sender account has sufficient balance', + }, + { + 'type': 'case', + 'code_line': 26, + 'description': 'Transfer money from one sender to receiver', + }, + { + 'type': 'expect', + 'code_line': 29, + 'description': 'Sender account balance decreased on the transfer money amount', + } + ] + } + + assert expected_test_case in intentions_json['accounts']['Accounts Service'] + + with expect('Test transfer money to non existing receiver account test case is in accounts service test cases'): + expected_test_case = { + 'file_path': 'fixtures/test_file.py', + 'class_name': None, + 'class_code_line': None, + 'function_name': 'test_transfer_money_to_non_existing_receiver_account', + 'function_code_line': 34, + 'intentions': [ + { + 'type': 'when', + 'code_line': 35, + 'description': 'Receiver account does not exist', + }, + { + 'type': 'case', + 'code_line': 38, + 'description': 'Transfer money from one sender to receiver', + }, + { + 'type': 'expect', + 'code_line': 41, + 'description': 'Receiver account does not exist error is raised', + } + ] + } + + assert expected_test_case in intentions_json['accounts']['Accounts Service'] + + def test_create_intentions_json_investments_service(self, remove_intentions_json) -> None: + with when('Tests folder with tests for investments service exist'): + path_to_tests_folder = './fixtures' + + with case('Create intentions JSON file'): + create_intentions_json(directory=path_to_tests_folder) + + with open('./.intentions/intentions.json', 'r') as intentions_json: + intentions_json = json.load(intentions_json) + + with expect('Invest money into stocks test case is in investments service test cases'): + expected_test_case = { + 'file_path': 'fixtures/test_file.py', + 'class_name': 'TestInvestmentsService', + 'class_code_line': 46, + 'function_name': 'test_invest_money_into_stocks', + 'function_code_line': 48, + 'intentions': [ + { + 'type': 'case', + 'code_line': 49, + 'description': 'Invest money into stocks', + }, + { + 'type': 'expect', + 'code_line': 52, + 'description': 'Stock is purchased', + }, + ] + } + + assert expected_test_case in intentions_json['investments']['Investments Service'] + + with expect('Invest money into crypto test case is in investments service test cases'): + expected_test_case = { + 'file_path': 'fixtures/test_file.py', + 'class_name': 'TestInvestmentsService', + 'class_code_line': 46, + 'function_name': 'test_invest_money_into_crypto', + 'function_code_line': 55, + 'intentions': [ + { + 'type': 'case', + 'code_line': 56, + 'description': 'Invest money into crypto', + }, + { + 'type': 'expect', + 'code_line': 59, + 'description': 'Crypto is purchased', + }, + ] + } + + assert expected_test_case in intentions_json['investments']['Investments Service'] + + with expect('Invest money into non-existing stocks test case is in investments service test cases'): + expected_test_case = { + 'file_path': 'fixtures/test_file.py', + 'class_name': None, + 'class_code_line': None, + 'function_name': 'test_invest_into_non_existing_stocks', + 'function_code_line': 64, + 'intentions': [ + { + 'type': 'when', + 'code_line': 65, + 'description': 'Stock to buy does not exist', + }, + { + 'type': 'case', + 'code_line': 68, + 'description': 'Invest money into stocks', + }, + { + 'type': 'expect', + 'code_line': 71, + 'description': 'Stock does not exist error is raised', + }, + ] + } + + assert expected_test_case in intentions_json['investments']['Investments Service'] + + def test_create_intentions_json_ignore_test_cases_without_intentions(self, remove_intentions_json) -> None: + with when('Tests folder with described test cases without intentions'): + path_to_tests_folder = './fixtures' + + with case('Create intentions JSON file'): + create_intentions_json(directory=path_to_tests_folder) + + with open('./.intentions/intentions.json', 'r') as intentions_json: + intentions_json = json.load(intentions_json) + + with expect('Described test case without intentions is not present in test cases'): + expected_test_case = { + 'file_path': 'fixtures/test_file.py', + 'class_name': None, + 'class_code_line': None, + 'function_name': 'test_sum', + 'function_code_line': 87, + 'intentions': [], + } + + assert expected_test_case not in intentions_json['investments']['Investments Service']