Skip to content

Commit

Permalink
Add intentions JSON file rendering (for UI docs) (#1)
Browse files Browse the repository at this point in the history
  • Loading branch information
dmytrostriletskyi authored Sep 10, 2024
1 parent 46ff6e9 commit 203deca
Show file tree
Hide file tree
Showing 20 changed files with 629 additions and 10 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,5 @@ venv.bak/

.idea/
.DS_Store

.intentions
2 changes: 1 addition & 1 deletion .project-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.0.5
0.0.6
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions fixtures/test_file.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions intentions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from intentions.main import (
case,
describe,
expect,
when,
)
31 changes: 23 additions & 8 deletions intentions/main.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -19,34 +28,40 @@ 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


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
Empty file added intentions/render/__init__.py
Empty file.
74 changes: 74 additions & 0 deletions intentions/render/ast_.py
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions intentions/render/dto.py
Original file line number Diff line number Diff line change
@@ -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]
14 changes: 14 additions & 0 deletions intentions/render/encoders.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 8 additions & 0 deletions intentions/render/enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from enum import Enum


class Intention(Enum):

WHEN = 'when'
CASE = 'case'
EXPECT = 'expect'
Loading

0 comments on commit 203deca

Please sign in to comment.