diff --git a/.python-version b/.python-version index 2e14a95..54c5196 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.8.6 +3.10.9 diff --git a/init.sh b/init.sh index 5d5aebd..fdb4b90 100755 --- a/init.sh +++ b/init.sh @@ -21,4 +21,4 @@ echo "$(date '+%Y-%m-%d_%H:%M:%S') $(poetry --version) will be used to install d # installs the project dependencies echo "$(date '+%Y-%m-%d_%H:%M:%S') poetry ${depsinstall}" -poetry ${depsinstall} \ No newline at end of file +poetry ${depsinstall} diff --git a/py2puml/asserts.py b/py2puml/asserts.py index 0b589cb..d18f46a 100644 --- a/py2puml/asserts.py +++ b/py2puml/asserts.py @@ -10,6 +10,7 @@ def assert_py2puml_is_file_content(domain_path: str, domain_module: str, diagram with open(diagram_filepath, 'r', encoding='utf8') as expected_puml_file: assert_py2puml_is_stringio(domain_path, domain_module, expected_puml_file) + def assert_py2puml_is_stringio(domain_path: str, domain_module: str, expected_content_stream: StringIO): # generates the PlantUML documentation puml_content = list(py2puml(domain_path, domain_module)) @@ -20,6 +21,9 @@ def assert_py2puml_is_stringio(domain_path: str, domain_module: str, expected_co def assert_multilines(actual_multilines: List[str], expected_multilines: Iterable[str]): line_index = 0 for line_index, (actual_line, expected_line) in enumerate(zip(actual_multilines, expected_multilines)): - assert actual_line == expected_line, f'actual and expected contents have changed at line {line_index + 1}: {actual_line=}, {expected_line=}' - - assert line_index + 1 == len(actual_multilines), f'actual and expected diagrams have {line_index + 1} lines' \ No newline at end of file + # print(actual_line[:-1]) + assert ( + actual_line == expected_line + ), f'actual and expected contents have changed at line {line_index + 1}: {actual_line=}, {expected_line=}' + + assert line_index + 1 == len(actual_multilines), f'actual and expected diagrams have {line_index + 1} lines' diff --git a/py2puml/cli.py b/py2puml/cli.py index d2bdf40..de8879e 100644 --- a/py2puml/cli.py +++ b/py2puml/cli.py @@ -18,7 +18,13 @@ def run(): argparser.add_argument('-v', '--version', action='version', version='py2puml 0.7.2') argparser.add_argument('path', metavar='path', type=str, help='the filepath to the domain') - argparser.add_argument('module', metavar='module', type=str, help='the module name of the domain', default=None) + argparser.add_argument( + 'module', + metavar='module', + type=str, + help='the module name of the domain', + default=None, + ) args = argparser.parse_args() print(''.join(py2puml(args.path, args.module))) diff --git a/py2puml/domain/package.py b/py2puml/domain/package.py index 9c208b5..1226517 100644 --- a/py2puml/domain/package.py +++ b/py2puml/domain/package.py @@ -1,9 +1,11 @@ from dataclasses import dataclass, field from typing import List + @dataclass class Package: - '''A folder or a python module''' + """A folder or a python module""" + name: str children: List['Package'] = field(default_factory=list) items_number: int = 0 diff --git a/py2puml/domain/umlclass.py b/py2puml/domain/umlclass.py index 5306980..75af5d2 100644 --- a/py2puml/domain/umlclass.py +++ b/py2puml/domain/umlclass.py @@ -1,15 +1,47 @@ -from typing import List -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Dict, List from py2puml.domain.umlitem import UmlItem + @dataclass class UmlAttribute: name: str type: str static: bool + +@dataclass +class UmlMethod: + name: str + arguments: Dict = field(default_factory=dict) + is_static: bool = False + is_class: bool = False + return_type: str = None + + def represent_as_puml(self): + items = [] + if self.is_static: + items.append('{static}') + if self.return_type: + items.append(self.return_type) + items.append(f'{self.name}({self.signature})') + return ' '.join(items) + + @property + def signature(self): + if self.arguments: + return ', '.join( + [ + f'{arg_type} {arg_name}' if arg_type else f'{arg_name}' + for arg_name, arg_type in self.arguments.items() + ] + ) + return '' + + @dataclass class UmlClass(UmlItem): attributes: List[UmlAttribute] + methods: List[UmlMethod] is_abstract: bool = False diff --git a/py2puml/domain/umlenum.py b/py2puml/domain/umlenum.py index c81c559..ae59471 100644 --- a/py2puml/domain/umlenum.py +++ b/py2puml/domain/umlenum.py @@ -1,13 +1,15 @@ -from typing import List from dataclasses import dataclass +from typing import List from py2puml.domain.umlitem import UmlItem + @dataclass class Member: name: str value: str + @dataclass class UmlEnum(UmlItem): members: List[Member] diff --git a/py2puml/domain/umlitem.py b/py2puml/domain/umlitem.py index 70a949f..12ed857 100644 --- a/py2puml/domain/umlitem.py +++ b/py2puml/domain/umlitem.py @@ -1,6 +1,7 @@ from dataclasses import dataclass + @dataclass class UmlItem: name: str - fqn: str \ No newline at end of file + fqn: str diff --git a/py2puml/domain/umlrelation.py b/py2puml/domain/umlrelation.py index 7f0cd83..e2cfd2e 100644 --- a/py2puml/domain/umlrelation.py +++ b/py2puml/domain/umlrelation.py @@ -1,11 +1,13 @@ from dataclasses import dataclass from enum import Enum, unique + @unique class RelType(Enum): COMPOSITION = '*' INHERITANCE = '<|' + @dataclass class UmlRelation: source_fqn: str diff --git a/py2puml/example.py b/py2puml/example.py index d516193..c43af83 100644 --- a/py2puml/example.py +++ b/py2puml/example.py @@ -2,9 +2,7 @@ if __name__ == '__main__': # outputs the PlantUML content in the terminal - print(''.join( - py2puml('py2puml/domain', 'py2puml.domain') - )) + print(''.join(py2puml('py2puml/domain', 'py2puml.domain'))) # writes the PlantUML content in a file with open('py2puml/py2puml.domain.puml', 'w', encoding='utf8') as puml_file: diff --git a/py2puml/export/namespace.py b/py2puml/export/namespace.py index a76cfa4..01f6318 100644 --- a/py2puml/export/namespace.py +++ b/py2puml/export/namespace.py @@ -3,7 +3,6 @@ from py2puml.domain.package import Package from py2puml.domain.umlitem import UmlItem - # templating constants INDENT = ' ' PUML_NAMESPACE_START_TPL = '{indentation}namespace {namespace_name} {{' @@ -11,23 +10,24 @@ def get_or_create_module_package(root_package: Package, domain_parts: List[str]) -> Package: - '''Returns or create the package containing the tail domain part''' + """Returns or create the package containing the tail domain part""" package = root_package for domain_part in domain_parts: - domain_package = next(( - sub_package for sub_package in package.children - if sub_package.name == domain_part - ), None) + domain_package = next( + (sub_package for sub_package in package.children if sub_package.name == domain_part), + None, + ) if domain_package is None: domain_package = Package(domain_part) package.children.append(domain_package) package = domain_package return package + def visit_package(package: Package, parent_namespace_names: Tuple[str], indentation_level: int) -> Iterable[str]: - ''' + """ Recursively visits the package and its subpackages to produce the PlantUML documentation about the namespace - ''' + """ package_with_items = package.items_number > 0 # prints the namespace if: # - it has inner uml_items @@ -49,7 +49,7 @@ def visit_package(package: Package, parent_namespace_names: Tuple[str], indentat # initializes the namespace decalaration but not yield yet: we don't know if it should be closed now or if there is inner content start_of_namespace_line = PUML_NAMESPACE_START_TPL.format( indentation=INDENT * indentation_level, - namespace_name='.'.join(namespace_names) + namespace_name='.'.join(namespace_names), ) parent_names = () if print_namespace else namespace_names @@ -72,10 +72,11 @@ def visit_package(package: Package, parent_namespace_names: Tuple[str], indentat else: yield PUML_NAMESPACE_END_TPL.format(indentation=start_of_namespace_line) + def build_packages_structure(uml_items: List[UmlItem]) -> Package: - ''' + """ Creates the Package arborescent structure with the given UML items with their fully-qualified module names - ''' + """ root_package = Package(None) for uml_item in uml_items: module_package = get_or_create_module_package(root_package, uml_item.fqn.split('.')[:-1]) @@ -83,10 +84,11 @@ def build_packages_structure(uml_items: List[UmlItem]) -> Package: return root_package + def puml_namespace_content(uml_items: List[UmlItem]) -> Iterable[str]: - ''' + """ Yields the documentation about the packages structure in the PlantUML syntax - ''' + """ root_package = Package(None) # creates the Package arborescent structure with the given UML items with their fully-qualified module names for uml_item in uml_items: @@ -95,4 +97,4 @@ def puml_namespace_content(uml_items: List[UmlItem]) -> Iterable[str]: # yields the documentation using a visitor pattern approach for namespace_line in visit_package(root_package, (), 0): - yield f"{namespace_line}" + yield f'{namespace_line}' diff --git a/py2puml/export/puml.py b/py2puml/export/puml.py index 3d179d3..ede08db 100644 --- a/py2puml/export/puml.py +++ b/py2puml/export/puml.py @@ -1,8 +1,8 @@ -from typing import List, Iterable +from typing import Iterable, List -from py2puml.domain.umlitem import UmlItem from py2puml.domain.umlclass import UmlClass from py2puml.domain.umlenum import UmlEnum +from py2puml.domain.umlitem import UmlItem from py2puml.domain.umlrelation import UmlRelation from py2puml.export.namespace import puml_namespace_content @@ -17,6 +17,7 @@ FEATURE_STATIC = ' {static}' FEATURE_INSTANCE = '' + def to_puml_content(diagram_name: str, uml_items: List[UmlItem], uml_relations: List[UmlRelation]) -> Iterable[str]: yield PUML_FILE_START.format(diagram_name=diagram_name) @@ -30,13 +31,26 @@ def to_puml_content(diagram_name: str, uml_items: List[UmlItem], uml_relations: uml_enum: UmlEnum = uml_item yield PUML_ITEM_START_TPL.format(item_type='enum', item_fqn=uml_enum.fqn) for member in uml_enum.members: - yield PUML_ATTR_TPL.format(attr_name=member.name, attr_type=member.value, staticity=FEATURE_STATIC) + yield PUML_ATTR_TPL.format( + attr_name=member.name, + attr_type=member.value, + staticity=FEATURE_STATIC, + ) yield PUML_ITEM_END elif isinstance(uml_item, UmlClass): uml_class: UmlClass = uml_item - yield PUML_ITEM_START_TPL.format(item_type='abstract class' if uml_item.is_abstract else 'class', item_fqn=uml_class.fqn) + yield PUML_ITEM_START_TPL.format( + item_type='abstract class' if uml_item.is_abstract else 'class', + item_fqn=uml_class.fqn, + ) for uml_attr in uml_class.attributes: - yield PUML_ATTR_TPL.format(attr_name=uml_attr.name, attr_type=uml_attr.type, staticity=FEATURE_STATIC if uml_attr.static else FEATURE_INSTANCE) + yield PUML_ATTR_TPL.format( + attr_name=uml_attr.name, + attr_type=uml_attr.type, + staticity=FEATURE_STATIC if uml_attr.static else FEATURE_INSTANCE, + ) + for uml_method in uml_class.methods: + yield f' {uml_method.represent_as_puml()}\n' yield PUML_ITEM_END else: raise TypeError(f'cannot process uml_item of type {uml_item.__class__}') @@ -46,7 +60,7 @@ def to_puml_content(diagram_name: str, uml_items: List[UmlItem], uml_relations: yield PUML_RELATION_TPL.format( source_fqn=uml_relation.source_fqn, rel_type=uml_relation.type.value, - target_fqn=uml_relation.target_fqn + target_fqn=uml_relation.target_fqn, ) yield PUML_FILE_FOOTER diff --git a/py2puml/inspection/inspectclass.py b/py2puml/inspection/inspectclass.py index 563f93e..d766cce 100644 --- a/py2puml/inspection/inspectclass.py +++ b/py2puml/inspection/inspectclass.py @@ -1,66 +1,71 @@ -from dataclasses import dataclass +from ast import AST, parse from importlib import import_module -from inspect import isabstract +from inspect import getsource, isabstract from re import compile as re_compile -from typing import Type, List, Dict - +from typing import Dict, List, Type +from py2puml.domain.umlclass import UmlAttribute, UmlClass from py2puml.domain.umlitem import UmlItem -from py2puml.domain.umlclass import UmlClass, UmlAttribute -from py2puml.domain.umlrelation import UmlRelation, RelType -from py2puml.parsing.astvisitors import shorten_compound_type_annotation -from py2puml.parsing.parseclassconstructor import parse_class_constructor +from py2puml.domain.umlrelation import RelType, UmlRelation +from py2puml.parsing.astvisitors import ClassVisitor, shorten_compound_type_annotation from py2puml.parsing.moduleresolver import ModuleResolver -# from py2puml.utils import investigate_domain_definition - +from py2puml.parsing.parseclassconstructor import parse_class_constructor CONCRETE_TYPE_PATTERN = re_compile("^<(?:class|enum) '([\\.|\\w]+)'>$") + def handle_inheritance_relation( class_type: Type, class_fqn: str, root_module_name: str, - domain_relations: List[UmlRelation] + domain_relations: List[UmlRelation], ): for base_type in getattr(class_type, '__bases__', ()): base_type_fqn = f'{base_type.__module__}.{base_type.__name__}' if base_type_fqn.startswith(root_module_name): - domain_relations.append( - UmlRelation(base_type_fqn, class_fqn, RelType.INHERITANCE) - ) + domain_relations.append(UmlRelation(base_type_fqn, class_fqn, RelType.INHERITANCE)) + def inspect_static_attributes( class_type: Type, class_type_fqn: str, root_module_name: str, domain_items_by_fqn: Dict[str, UmlItem], - domain_relations: List[UmlRelation] + domain_relations: List[UmlRelation], ) -> List[UmlAttribute]: - ''' + """ Adds the definitions: - of the inspected type - of its static attributes from the class annotations (type and relation) - ''' + """ # defines the class being inspected definition_attrs: List[UmlAttribute] = [] uml_class = UmlClass( name=class_type.__name__, fqn=class_type_fqn, attributes=definition_attrs, - is_abstract=isabstract(class_type) + is_abstract=isabstract(class_type), + methods=[], ) domain_items_by_fqn[class_type_fqn] = uml_class # investigate_domain_definition(class_type) type_annotations = getattr(class_type, '__annotations__', None) + parent_class_type = getattr(class_type, '__bases__', None)[0] + parent_type_annotations = getattr(parent_class_type, '__annotations__', None) + if type_annotations is not None: # stores only once the compositions towards the same class - relations_by_target_fqdn: Dict[str: UmlRelation] = {} + relations_by_target_fqdn: Dict[str:UmlRelation] = {} # utility which outputs the fully-qualified name of the attribute types module_resolver = ModuleResolver(import_module(class_type.__module__)) - # builds the definitions of the class attrbutes and their relationships by iterating over the type annotations + # builds the definitions of the class attributes and their relationships by iterating over the type annotations for attr_name, attr_class in type_annotations.items(): + # Skip class attributes accidentally inherited from parent class + if parent_type_annotations and attr_name in parent_type_annotations.keys(): + continue + attr_raw_type = str(attr_class) concrete_type_match = CONCRETE_TYPE_PATTERN.search(attr_raw_type) # basic type @@ -75,12 +80,17 @@ def inspect_static_attributes( attr_type = concrete_type # compound type (tuples, lists, dictionaries, etc.) else: - attr_type, full_namespaced_definitions = shorten_compound_type_annotation(attr_raw_type, module_resolver) - relations_by_target_fqdn.update({ - attr_fqn: UmlRelation(uml_class.fqn, attr_fqn, RelType.COMPOSITION) - for attr_fqn in full_namespaced_definitions - if attr_fqn.startswith(root_module_name) - }) + ( + attr_type, + full_namespaced_definitions, + ) = shorten_compound_type_annotation(attr_raw_type, module_resolver) + relations_by_target_fqdn.update( + { + attr_fqn: UmlRelation(uml_class.fqn, attr_fqn, RelType.COMPOSITION) + for attr_fqn in full_namespaced_definitions + if attr_fqn.startswith(root_module_name) + } + ) uml_attr = UmlAttribute(attr_name, attr_type, static=True) definition_attrs.append(uml_attr) @@ -89,37 +99,55 @@ def inspect_static_attributes( return definition_attrs + +def inspect_methods(definition_methods: List, class_type: Type, root_module_name: str): + """This function parses a class using AST to identify methods.""" + print(f'inspecting {class_type.__name__} from {class_type.__module__}') + class_source: str = getsource(class_type) + class_ast: AST = parse(class_source) + visitor = ClassVisitor(class_type, root_module_name) + visitor.visit(class_ast) + for method in visitor.uml_methods: + definition_methods.append(method) + + def inspect_class_type( class_type: Type, class_type_fqn: str, root_module_name: str, domain_items_by_fqn: Dict[str, UmlItem], - domain_relations: List[UmlRelation] + domain_relations: List[UmlRelation], ): attributes = inspect_static_attributes( - class_type, class_type_fqn, root_module_name, - domain_items_by_fqn, domain_relations + class_type, + class_type_fqn, + root_module_name, + domain_items_by_fqn, + domain_relations, ) instance_attributes, compositions = parse_class_constructor(class_type, class_type_fqn, root_module_name) attributes.extend(instance_attributes) domain_relations.extend(compositions.values()) + inspect_methods(domain_items_by_fqn[class_type_fqn].methods, class_type, root_module_name) + handle_inheritance_relation(class_type, class_type_fqn, root_module_name, domain_relations) + def inspect_dataclass_type( - class_type: Type[dataclass], + class_type: Type, class_type_fqn: str, root_module_name: str, domain_items_by_fqn: Dict[str, UmlItem], - domain_relations: List[UmlRelation] + domain_relations: List[UmlRelation], ): for attribute in inspect_static_attributes( class_type, class_type_fqn, root_module_name, domain_items_by_fqn, - domain_relations + domain_relations, ): attribute.static = False - handle_inheritance_relation(class_type, class_type_fqn, root_module_name, domain_relations) \ No newline at end of file + handle_inheritance_relation(class_type, class_type_fqn, root_module_name, domain_relations) diff --git a/py2puml/inspection/inspectenum.py b/py2puml/inspection/inspectenum.py index 374aac9..2decb06 100644 --- a/py2puml/inspection/inspectenum.py +++ b/py2puml/inspection/inspectenum.py @@ -1,20 +1,13 @@ - -from typing import Dict, Type from enum import Enum +from typing import Dict, Type +from py2puml.domain.umlenum import Member, UmlEnum from py2puml.domain.umlitem import UmlItem -from py2puml.domain.umlenum import UmlEnum, Member -def inspect_enum_type( - enum_type: Type[Enum], - enum_type_fqn: str, - domain_items_by_fqn: Dict[str, UmlItem] -): + +def inspect_enum_type(enum_type: Type[Enum], enum_type_fqn: str, domain_items_by_fqn: Dict[str, UmlItem]): domain_items_by_fqn[enum_type_fqn] = UmlEnum( name=enum_type.__name__, fqn=enum_type_fqn, - members=[ - Member(name=enum_member.name, value=enum_member.value) - for enum_member in enum_type - ] + members=[Member(name=enum_member.name, value=enum_member.value) for enum_member in enum_type], ) diff --git a/py2puml/inspection/inspectmodule.py b/py2puml/inspection/inspectmodule.py index 4e8ae0d..6f30fa9 100644 --- a/py2puml/inspection/inspectmodule.py +++ b/py2puml/inspection/inspectmodule.py @@ -1,14 +1,12 @@ - -from typing import Dict, Iterable, List, Type -from types import ModuleType - from dataclasses import is_dataclass from enum import Enum from inspect import getmembers, isclass +from types import ModuleType +from typing import Dict, Iterable, List, Type from py2puml.domain.umlitem import UmlItem from py2puml.domain.umlrelation import UmlRelation -from py2puml.inspection.inspectclass import inspect_dataclass_type, inspect_class_type +from py2puml.inspection.inspectclass import inspect_class_type, inspect_dataclass_type from py2puml.inspection.inspectenum import inspect_enum_type from py2puml.inspection.inspectnamedtuple import inspect_namedtuple_type @@ -18,19 +16,24 @@ def filter_domain_definitions(module: ModuleType, root_module_name: str) -> Iter definition_type = getattr(module, definition_key) if isclass(definition_type): definition_members = getmembers(definition_type) - definition_module_member = next(( - member for member in definition_members - # ensures that the type belongs to the module being parsed - if member[0] == '__module__' and member[1].startswith(root_module_name) - ), None) + definition_module_member = next( + ( + member + for member in definition_members + # ensures that the type belongs to the module being parsed + if member[0] == '__module__' and member[1].startswith(root_module_name) + ), + None, + ) if definition_module_member is not None: yield definition_type + def inspect_domain_definition( definition_type: Type, root_module_name: str, domain_items_by_fqn: Dict[str, UmlItem], - domain_relations: List[UmlRelation] + domain_relations: List[UmlRelation], ): definition_type_fqn = f'{definition_type.__module__}.{definition_type.__name__}' if definition_type_fqn not in domain_items_by_fqn: @@ -40,20 +43,27 @@ def inspect_domain_definition( inspect_namedtuple_type(definition_type, definition_type_fqn, domain_items_by_fqn) elif is_dataclass(definition_type): inspect_dataclass_type( - definition_type, definition_type_fqn, - root_module_name, domain_items_by_fqn, domain_relations + definition_type, + definition_type_fqn, + root_module_name, + domain_items_by_fqn, + domain_relations, ) else: inspect_class_type( - definition_type, definition_type_fqn, - root_module_name, domain_items_by_fqn, domain_relations + definition_type, + definition_type_fqn, + root_module_name, + domain_items_by_fqn, + domain_relations, ) + def inspect_module( domain_item_module: ModuleType, root_module_name: str, domain_items_by_fqn: Dict[str, UmlItem], - domain_relations: List[UmlRelation] + domain_relations: List[UmlRelation], ): # processes only the definitions declared or imported within the given root module for definition_type in filter_domain_definitions(domain_item_module, root_module_name): diff --git a/py2puml/inspection/inspectnamedtuple.py b/py2puml/inspection/inspectnamedtuple.py index 0b80ee9..b954d84 100644 --- a/py2puml/inspection/inspectnamedtuple.py +++ b/py2puml/inspection/inspectnamedtuple.py @@ -1,20 +1,17 @@ - from typing import Dict, Type -from py2puml.domain.umlclass import UmlClass, UmlAttribute +from py2puml.domain.umlclass import UmlAttribute, UmlClass from py2puml.domain.umlitem import UmlItem def inspect_namedtuple_type( namedtuple_type: Type, namedtuple_type_fqn: str, - domain_items_by_fqn: Dict[str, UmlItem] + domain_items_by_fqn: Dict[str, UmlItem], ): domain_items_by_fqn[namedtuple_type_fqn] = UmlClass( name=namedtuple_type.__name__, fqn=namedtuple_type_fqn, - attributes=[ - UmlAttribute(tuple_field, 'Any', False) - for tuple_field in namedtuple_type._fields - ] + attributes=[UmlAttribute(tuple_field, 'Any', False) for tuple_field in namedtuple_type._fields], + methods=[], ) diff --git a/py2puml/inspection/inspectpackage.py b/py2puml/inspection/inspectpackage.py index 983b3f7..0d5942e 100644 --- a/py2puml/inspection/inspectpackage.py +++ b/py2puml/inspection/inspectpackage.py @@ -12,14 +12,9 @@ def inspect_package( domain_path: str, domain_module: str, domain_items_by_fqn: Dict[str, UmlItem], - domain_relations: List[UmlRelation] + domain_relations: List[UmlRelation], ): for _, name, is_pkg in walk_packages([domain_path], f'{domain_module}.'): if not is_pkg: domain_item_module: ModuleType = import_module(name) - inspect_module( - domain_item_module, - domain_module, - domain_items_by_fqn, - domain_relations - ) + inspect_module(domain_item_module, domain_module, domain_items_by_fqn, domain_relations) diff --git a/py2puml/parsing/astvisitors.py b/py2puml/parsing/astvisitors.py index 953d48a..1560762 100644 --- a/py2puml/parsing/astvisitors.py +++ b/py2puml/parsing/astvisitors.py @@ -1,103 +1,201 @@ - -from typing import Dict, List, Tuple - -from ast import ( - NodeVisitor, arg, expr, - FunctionDef, Assign, AnnAssign, - Attribute, Name, Subscript, get_source_segment -) +from ast import AnnAssign, Assign, Attribute, FunctionDef, Name, NodeVisitor, Subscript, arg, expr, get_source_segment from collections import namedtuple +from typing import Dict, List, Tuple, Type + +from py2puml.domain.umlclass import UmlAttribute, UmlMethod +from py2puml.domain.umlrelation import RelType, UmlRelation +from py2puml.parsing.compoundtypesplitter import SPLITTING_CHARACTERS, CompoundTypeSplitter +from py2puml.parsing.moduleresolver import ModuleResolver -from py2puml.domain.umlclass import UmlAttribute -from py2puml.domain.umlrelation import UmlRelation, RelType -from py2puml.parsing.compoundtypesplitter import CompoundTypeSplitter, SPLITTING_CHARACTERS -from py2puml.parsing.moduleresolver import ModuleResolver, NamespacedType +Argument = namedtuple('Argument', ['id', 'type_expr']) -Variable = namedtuple('Variable', ['id', 'type_expr']) +class SignatureArgumentsCollector(NodeVisitor): + """ + Collects the arguments name and type annotations from the signature of a method + """ -class SignatureVariablesCollector(NodeVisitor): - ''' - Collects the variables and their type annotations from the signature of a constructor method - ''' - def __init__(self, constructor_source: str, *args, **kwargs): + def __init__(self, skip_self=False, *args, **kwargs): super().__init__(*args, **kwargs) - self.constructor_source = constructor_source + self.skip_self = skip_self self.class_self_id: str = None - self.variables: List[Variable] = [] + self.arguments: List[Argument] = [] + self.datatypes = {} def visit_arg(self, node: arg): - variable = Variable(node.arg, node.annotation) - - # first constructor variable is the name for the 'self' reference - if self.class_self_id is None: - self.class_self_id = variable.id - # other arguments are constructor parameters + argument = Argument(node.arg, node.annotation) + if node.annotation: + type_visitor = TypeVisitor() + datatype = type_visitor.visit(node.annotation) else: - self.variables.append(variable) + datatype = None + self.datatypes[node.arg] = datatype + # first constructor argument is the name for the 'self' reference + if self.class_self_id is None and not self.skip_self: + self.class_self_id = argument.id + # other arguments are constructor parameters + self.arguments.append(argument) class AssignedVariablesCollector(NodeVisitor): - '''Parses the target of an assignment statement to detect whether the value is assigned to a variable or an instance attribute''' + """Parses the target of an assignment statement to detect whether the value is assigned to a variable or an instance attribute""" + def __init__(self, class_self_id: str, annotation: expr): self.class_self_id: str = class_self_id self.annotation: expr = annotation - self.variables: List[Variable] = [] - self.self_attributes: List[Variable] = [] + self.variables: List[Argument] = [] + self.self_attributes: List[Argument] = [] def visit_Name(self, node: Name): - ''' + """ Detects declarations of new variables - ''' + """ if node.id != self.class_self_id: - self.variables.append(Variable(node.id, self.annotation)) + self.variables.append(Argument(node.id, self.annotation)) def visit_Attribute(self, node: Attribute): - ''' + """ Detects declarations of new attributes on 'self' - ''' + """ if isinstance(node.value, Name) and node.value.id == self.class_self_id: - self.self_attributes.append(Variable(node.attr, self.annotation)) + self.self_attributes.append(Argument(node.attr, self.annotation)) def visit_Subscript(self, node: Subscript): - ''' + """ Assigns a value to a subscript of an existing variable: must be skipped - ''' + """ pass +class ClassVisitor(NodeVisitor): + def __init__(self, class_type: Type, root_fqn: str, *args, **kwargs): + super().__init__(*args, **kwargs) + self.uml_methods: List[UmlMethod] = [] + + def visit_FunctionDef(self, node: FunctionDef): + method_visitor = MethodVisitor() + method_visitor.visit(node) + self.uml_methods.append(method_visitor.uml_method) + + +class TypeVisitor(NodeVisitor): + """Returns a string representation of a data type. Supports nested compound data types""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def visit_Name(self, node): + return node.id + + def visit_Constant(self, node): + return node.value + + def visit_Subscript(self, node: Subscript): + """Visit node of type ast.Subscript and returns a string representation of the compound datatype. Iterate + over elements contained in slice attribute by calling recursively visit() method of new instances of + TypeVisitor. This allows to resolve nested compound datatype.""" + + datatypes = [] + + # print(f'{node.slice=}') + # print(f'{dir(node.slice)=}') + # print(f'{dir(node.slice.value)=}') + + if hasattr(node.slice.value, 'elts'): + for child_node in node.slice.value.elts: + child_visitor = TypeVisitor() + datatypes.append(child_visitor.visit(child_node)) + else: + child_visitor = TypeVisitor() + datatypes.append(child_visitor.visit(node.slice.value)) + + joined_datatypes = ', '.join(datatypes) + + return f'{node.value.id}[{joined_datatypes}]' + + +class MethodVisitor(NodeVisitor): + """ + Node visitor subclass used to walk the abstract syntax tree of a method class and identify method arguments. + + If the method is the class constructor, instance attributes (and their type) are also identified by looking both at the constructor signature and constructor's body. When searching in the constructor's body, the visitor looks for relevant assignments (with and without type annotation). + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.variables_namespace: List[Argument] = [] + self.uml_method: UmlMethod + + def visit_FunctionDef(self, node: FunctionDef): + decorators = [decorator.id for decorator in node.decorator_list] + is_static = 'staticmethod' in decorators + is_class = 'classmethod' in decorators + arguments_collector = SignatureArgumentsCollector(skip_self=is_static) + arguments_collector.visit(node) + self.variables_namespace = arguments_collector.arguments + + self.uml_method = UmlMethod(name=node.name, is_static=is_static, is_class=is_class) + + for argument in arguments_collector.arguments: + if argument.id == arguments_collector.class_self_id: + self.uml_method.arguments[argument.id] = None + if argument.type_expr: + if hasattr(argument.type_expr, 'id'): + self.uml_method.arguments[argument.id] = argument.type_expr.id + else: + self.uml_method.arguments[argument.id] = arguments_collector.datatypes[argument.id] + else: + self.uml_method.arguments[argument.id] = None + + if node.returns is not None: + return_visitor = TypeVisitor() + self.uml_method.return_type = return_visitor.visit(node.returns) + + class ConstructorVisitor(NodeVisitor): - ''' + """ Identifies the attributes (and infer their type) assigned to self in the body of a constructor method - ''' - def __init__(self, constructor_source: str, class_name: str, root_fqn: str, module_resolver: ModuleResolver, *args, **kwargs): + """ + + def __init__( + self, + constructor_source: str, + class_name: str, + root_fqn: str, + module_resolver: ModuleResolver, + *args, + **kwargs, + ): super().__init__(*args, **kwargs) self.constructor_source = constructor_source self.class_fqn: str = f'{module_resolver.module.__name__}.{class_name}' self.root_fqn = root_fqn self.module_resolver = module_resolver self.class_self_id: str - self.variables_namespace: List[Variable] = [] + self.variables_namespace: List[Argument] = [] self.uml_attributes: List[UmlAttribute] = [] self.uml_relations_by_target_fqn: Dict[str, UmlRelation] = {} def extend_relations(self, target_fqns: List[str]): - self.uml_relations_by_target_fqn.update({ - target_fqn: UmlRelation(self.class_fqn, target_fqn, RelType.COMPOSITION) - for target_fqn in target_fqns - if target_fqn.startswith(self.root_fqn) and ( - target_fqn not in self.uml_relations_by_target_fqn - ) - }) - - def get_from_namespace(self, variable_id: str) -> Variable: - return next(( - variable - # variables namespace is iterated antichronologically - # to account for variables being overridden - for variable in self.variables_namespace[::-1] - if variable.id == variable_id - ), None) + self.uml_relations_by_target_fqn.update( + { + target_fqn: UmlRelation(self.class_fqn, target_fqn, RelType.COMPOSITION) + for target_fqn in target_fqns + if target_fqn.startswith(self.root_fqn) and (target_fqn not in self.uml_relations_by_target_fqn) + } + ) + + def get_from_namespace(self, variable_id: str) -> Argument: + return next( + ( + variable + # variables namespace is iterated antichronologically + # to account for variables being overridden + for variable in self.variables_namespace[::-1] + if variable.id == variable_id + ), + None, + ) def generic_visit(self, node): NodeVisitor.generic_visit(self, node) @@ -105,10 +203,10 @@ def generic_visit(self, node): def visit_FunctionDef(self, node: FunctionDef): # retrieves constructor arguments ('self' reference and typed arguments) if node.name == '__init__': - variables_collector = SignatureVariablesCollector(self.constructor_source) + variables_collector = SignatureArgumentsCollector() variables_collector.visit(node) self.class_self_id: str = variables_collector.class_self_id - self.variables_namespace = variables_collector.variables + self.variables_namespace = variables_collector.arguments self.generic_visit(node) @@ -132,71 +230,71 @@ def visit_Assign(self, node: Assign): variables_collector.visit(assigned_target) # attempts to infer attribute type when a single attribute is assigned to a variable - if ( - len(variables_collector.self_attributes) == 1 - ) and ( - isinstance(node.value, Name) - ): + if (len(variables_collector.self_attributes) == 1) and (isinstance(node.value, Name)): assigned_variable = self.get_from_namespace(node.value.id) if assigned_variable is not None: - short_type, full_namespaced_definitions = self.derive_type_annotation_details( - assigned_variable.type_expr - ) + ( + short_type, + full_namespaced_definitions, + ) = self.derive_type_annotation_details(assigned_variable.type_expr) self.uml_attributes.append( - UmlAttribute( - variables_collector.self_attributes[0].id, short_type, False - ) + UmlAttribute(variables_collector.self_attributes[0].id, short_type, False) ) self.extend_relations(full_namespaced_definitions) else: for variable in variables_collector.self_attributes: - short_type, full_namespaced_definitions = self.derive_type_annotation_details(variable.type_expr) + ( + short_type, + full_namespaced_definitions, + ) = self.derive_type_annotation_details(variable.type_expr) self.uml_attributes.append(UmlAttribute(variable.id, short_type, static=False)) self.extend_relations(full_namespaced_definitions) # other assignments were done in new variables that can shadow existing ones self.variables_namespace.extend(variables_collector.variables) - def derive_type_annotation_details(self, annotation: expr) -> Tuple[str, List[str]]: - ''' + """ From a type annotation, derives: - a short version of the type (withenum.TimeUnit -> TimeUnit, Tuple[withenum.TimeUnit] -> Tuple[TimeUnit]) - a list of the full-namespaced definitions involved in the type annotation (in order to build the relationships) - ''' + """ if annotation is None: return None, [] # primitive type, object definition if isinstance(annotation, Name): - full_namespaced_type, short_type = self.module_resolver.resolve_full_namespace_type( - annotation.id - ) + ( + full_namespaced_type, + short_type, + ) = self.module_resolver.resolve_full_namespace_type(annotation.id) return short_type, [full_namespaced_type] # definition from module elif isinstance(annotation, Attribute): - full_namespaced_type, short_type = self.module_resolver.resolve_full_namespace_type( + ( + full_namespaced_type, + short_type, + ) = self.module_resolver.resolve_full_namespace_type( get_source_segment(self.constructor_source, annotation) ) return short_type, [full_namespaced_type] # compound type (List[...], Tuple[Dict[str, float], module.DomainType], etc.) elif isinstance(annotation, Subscript): - return shorten_compound_type_annotation( - get_source_segment(self.constructor_source, annotation), - self.module_resolver - ) - + source_segment = get_source_segment(self.constructor_source, annotation) + short_type, associated_types = shorten_compound_type_annotation(source_segment, self.module_resolver) + return short_type, associated_types return None, [] + def shorten_compound_type_annotation(type_annotation: str, module_resolver: ModuleResolver) -> Tuple[str, List[str]]: - ''' + """ In the string representation of a compound type annotation, the elementary types can be prefixed by their packages or sub-packages. Like in 'Dict[datetime.datetime,typing.List[Worker]]'. This function returns a tuple of 2 values: - a string representation with shortened types for display purposes in the PlantUML documentation: 'Dict[datetime, List[Worker]]' (note: a space is inserted after each coma for readability sake) - a list of the fully-qualified types involved in the annotation: ['typing.Dict', 'datetime.datetime', 'typing.List', 'mymodule.Worker'] - ''' + """ compound_type_parts: List[str] = CompoundTypeSplitter(type_annotation, module_resolver.module.__name__).get_parts() compound_short_type_parts: List[str] = [] associated_types: List[str] = [] @@ -208,11 +306,14 @@ def shorten_compound_type_annotation(type_annotation: str, module_resolver: Modu compound_short_type_parts.append(' ') # replaces each type definition by its short class name else: - full_namespaced_type, short_type = module_resolver.resolve_full_namespace_type( - compound_type_part - ) + ( + full_namespaced_type, + short_type, + ) = module_resolver.resolve_full_namespace_type(compound_type_part) if short_type is None: - raise ValueError(f'Could not resolve type {compound_type_part} in module {module_resolver.module}: it needs to be imported explicitely.') + raise ValueError( + f'Could not resolve type {compound_type_part} in module {module_resolver.module}: it needs to be imported explicitely.' + ) else: compound_short_type_parts.append(short_type) associated_types.append(full_namespaced_type) diff --git a/py2puml/parsing/compoundtypesplitter.py b/py2puml/parsing/compoundtypesplitter.py index 415a344..ff85119 100644 --- a/py2puml/parsing/compoundtypesplitter.py +++ b/py2puml/parsing/compoundtypesplitter.py @@ -1,34 +1,39 @@ - -from re import compile as re_compile, Pattern +from re import Pattern +from re import compile as re_compile from typing import Tuple - FORWARD_REFERENCES: Pattern = re_compile(r"ForwardRef\('([^']+)'\)") IS_COMPOUND_TYPE: Pattern = re_compile(r'^[a-z|A-Z|0-9|\[|\]|\.|,|\s|_]+$') SPLITTING_CHARACTERS = ('[', ']', ',') def remove_forward_references(compound_type_annotation: str, module_name: str) -> str: - ''' + """ Removes the forward reference mention from the string representation of a type annotation. This happens when a class attribute refers to the class being defined (where Person.friends is of type List[Person]) The type which is referred to is prefixed by the module where it is defined to help resolution. - ''' - return None if compound_type_annotation is None else FORWARD_REFERENCES.sub(f'{module_name}.\\1', compound_type_annotation) + """ + return ( + None + if compound_type_annotation is None + else FORWARD_REFERENCES.sub(f'{module_name}.\\1', compound_type_annotation) + ) + class CompoundTypeSplitter: - ''' + """ Splits the representation of a compound type annotation into a list of: - its components (that can be resolved against the module where the type annotation was found) - its structuring characters: '[', ']' and ',' - ''' + """ + def __init__(self, compound_type_annotation: str, module_name: str): resolved_type_annotations = remove_forward_references(compound_type_annotation, module_name) if (resolved_type_annotations is None) or not IS_COMPOUND_TYPE.match(resolved_type_annotations): raise ValueError(f'{compound_type_annotation} seems to be an invalid type annotation') self.compound_type_annotation = resolved_type_annotations - + def get_parts(self) -> Tuple[str]: parts = [self.compound_type_annotation] for splitting_character in SPLITTING_CHARACTERS: @@ -39,10 +44,6 @@ def get_parts(self) -> Tuple[str]: if len(splitted_parts) > 1: for splitted_part in splitted_parts[1:]: new_parts.extend([splitting_character, splitted_part]) - parts = [ - new_part.strip() - for new_part in new_parts - if len(new_part.strip()) > 0 - ] + parts = [new_part.strip() for new_part in new_parts if len(new_part.strip()) > 0] return tuple(parts) diff --git a/py2puml/parsing/moduleresolver.py b/py2puml/parsing/moduleresolver.py index a519560..5a62177 100644 --- a/py2puml/parsing/moduleresolver.py +++ b/py2puml/parsing/moduleresolver.py @@ -1,21 +1,23 @@ -from inspect import isclass from functools import reduce -from typing import Type, Iterable, List, NamedTuple +from inspect import isclass from types import ModuleType +from typing import Iterable, List, NamedTuple, Type class NamespacedType(NamedTuple): - ''' + """ Information of a value type: - the module-prefixed type name - the short type name - ''' + """ + full_namespace: str type_name: str EMPTY_NAMESPACED_TYPE = NamespacedType(None, None) + def search_in_module_or_builtins(searched_module: ModuleType, namespace: str): if searched_module is None: return None @@ -31,24 +33,17 @@ def search_in_module_or_builtins(searched_module: ModuleType, namespace: str): def search_in_module(namespaces: List[str], module: ModuleType): - leaf_type: Type = reduce( - search_in_module_or_builtins, - namespaces, - module - ) + leaf_type: Type = reduce(search_in_module_or_builtins, namespaces, module) if leaf_type is None: return EMPTY_NAMESPACED_TYPE else: # https://bugs.python.org/issue34422#msg323772 short_type = getattr(leaf_type, '__name__', getattr(leaf_type, '_name', None)) - return NamespacedType( - f'{leaf_type.__module__}.{short_type}', - short_type - ) + return NamespacedType(f'{leaf_type.__module__}.{short_type}', short_type) class ModuleResolver: - ''' + """ Given a module and a partially namespaced type name, returns a tuple of information about the type: - the full-namespaced type name, to draw relationships between types without ambiguity - the short type name, for display purposes @@ -58,7 +53,7 @@ class ModuleResolver: - when the partially namespaced type is found during class inspection (dataclasses, class static variables, named tuples, enums) The two approaches are a bit entangled for now, they could be separated a bit more for performance sake. - ''' + """ def __init__(self, module: ModuleType): self.module = module @@ -67,27 +62,34 @@ def __repr__(self) -> str: return f'ModuleResolver({self.module})' def resolve_full_namespace_type(self, partial_dotted_path: str) -> NamespacedType: - ''' + """ Returns a tuple of 2 strings: - the full namespaced type - the short named type - ''' + """ if partial_dotted_path is None: return EMPTY_NAMESPACED_TYPE def string_repr(module_attribute) -> str: - return f'{module_attribute.__module__}.{module_attribute.__name__}' if isclass(module_attribute) else f'{module_attribute}' + return ( + f'{module_attribute.__module__}.{module_attribute.__name__}' + if isclass(module_attribute) + else f'{module_attribute}' + ) # searches the class in the module imports namespaced_types_iter: Iterable[NamespacedType] = ( NamespacedType(string_repr(getattr(self.module, module_var)), module_var) for module_var in vars(self.module) ) - found_namespaced_type = next(( - namespaced_type - for namespaced_type in namespaced_types_iter - if namespaced_type.full_namespace == partial_dotted_path - ), None) + found_namespaced_type = next( + ( + namespaced_type + for namespaced_type in namespaced_types_iter + if namespaced_type.full_namespace == partial_dotted_path + ), + None, + ) # searches the class in the builtins if found_namespaced_type is None: diff --git a/py2puml/parsing/parseclassconstructor.py b/py2puml/parsing/parseclassconstructor.py index 08e5c63..f3d1c6a 100644 --- a/py2puml/parsing/parseclassconstructor.py +++ b/py2puml/parsing/parseclassconstructor.py @@ -1,32 +1,33 @@ - -from typing import Dict, List, Tuple, Type - -from ast import parse, AST +from ast import AST, parse from importlib import import_module from inspect import getsource, unwrap from textwrap import dedent +from typing import Dict, List, Tuple, Type from py2puml.domain.umlclass import UmlAttribute from py2puml.domain.umlrelation import UmlRelation from py2puml.parsing.astvisitors import ConstructorVisitor from py2puml.parsing.moduleresolver import ModuleResolver + def parse_class_constructor( - class_type: Type, - class_fqn: str, - root_module_name: str + class_type: Type, class_fqn: str, root_module_name: str ) -> Tuple[List[UmlAttribute], Dict[str, UmlRelation]]: constructor = getattr(class_type, '__init__', None) - # conditions to meet in order to parse the AST of a constructor + # conditions to meet in order to parse the AST of a constructor if ( - # the constructor must be defined - constructor is None - ) or ( - # the constructor's source code must be available - not hasattr(constructor, '__code__') - ) or ( - # the constructor must belong to the parsed class (not its parent's one) - not constructor.__qualname__.endswith(f'{class_type.__name__}.__init__') + ( + # the constructor must be defined + constructor is None + ) + or ( + # the constructor's source code must be available + not hasattr(constructor, '__code__') + ) + or ( + # the constructor must belong to the parsed class (not its parent's one) + not constructor.__qualname__.endswith(f'{class_type.__name__}.__init__') + ) ): return [], {} diff --git a/py2puml/py2puml.domain.puml b/py2puml/py2puml.domain.puml index 5eeece8..2e1caf0 100644 --- a/py2puml/py2puml.domain.puml +++ b/py2puml/py2puml.domain.puml @@ -18,12 +18,20 @@ class py2puml.domain.umlclass.UmlAttribute { } class py2puml.domain.umlclass.UmlClass { attributes: List[UmlAttribute] + methods: List[UmlMethod] is_abstract: bool } class py2puml.domain.umlitem.UmlItem { name: str fqn: str } +class py2puml.domain.umlclass.UmlMethod { + name: str + arguments: Dict + is_static: bool + is_class: bool + return_type: str +} class py2puml.domain.umlenum.Member { name: str value: str @@ -42,6 +50,7 @@ class py2puml.domain.umlrelation.UmlRelation { } py2puml.domain.package.Package *-- py2puml.domain.package.Package py2puml.domain.umlclass.UmlClass *-- py2puml.domain.umlclass.UmlAttribute +py2puml.domain.umlclass.UmlClass *-- py2puml.domain.umlclass.UmlMethod py2puml.domain.umlitem.UmlItem <|-- py2puml.domain.umlclass.UmlClass py2puml.domain.umlenum.UmlEnum *-- py2puml.domain.umlenum.Member py2puml.domain.umlitem.UmlItem <|-- py2puml.domain.umlenum.UmlEnum diff --git a/py2puml/py2puml.parsing.puml b/py2puml/py2puml.parsing.puml new file mode 100644 index 0000000..7156db2 --- /dev/null +++ b/py2puml/py2puml.parsing.puml @@ -0,0 +1,63 @@ +@startuml +class py2puml.parsing.astvisitors.AssignedVariablesCollector { + class_self_id: str + annotation: expr + variables: List[Variable] + self_attributes: List[Variable] + generic_visit(self, node) + visit(self, node) + visit_Attribute(self, node: ast.Attribute) + visit_Constant(self, node) + visit_Name(self, node: ast.Name) + visit_Subscript(self, node: ast.Subscript) +} +class py2puml.parsing.compoundtypesplitter.CompoundTypeSplitter { + compound_type_annotation: str + get_parts(self) -> Tuple[str] +} +class py2puml.parsing.astvisitors.ConstructorVisitor { + constructor_source: str + class_fqn: str + root_fqn: str + module_resolver: ModuleResolver + class_self_id: str + variables_namespace: List[Variable] + uml_attributes: List[UmlAttribute] + uml_relations_by_target_fqn: Dict[str, UmlRelation] + derive_type_annotation_details(self, annotation: ast.expr) -> Tuple[str, List[str]] + extend_relations(self, target_fqns: List[str]) + generic_visit(self, node) + get_from_namespace(self, variable_id: str) -> py2puml.parsing.astvisitors.Variable + visit(self, node) + visit_AnnAssign(self, node: ast.AnnAssign) + visit_Assign(self, node: ast.Assign) + visit_Constant(self, node) + visit_FunctionDef(self, node: ast.FunctionDef) +} +class py2puml.parsing.moduleresolver.ModuleResolver { + module: module + get_module_full_name(self) -> str + resolve_full_namespace_type(self, partial_dotted_path: str) -> Tuple[str, str] +} +class py2puml.parsing.moduleresolver.NamespacedType { + full_namespace: Any + type_name: Any +} +class py2puml.parsing.astvisitors.SignatureVariablesCollector { + constructor_source: str + class_self_id: str + variables: List[Variable] + generic_visit(self, node) + visit(self, node) + visit_Constant(self, node) + visit_arg(self, node: ast.arg) +} +class py2puml.parsing.astvisitors.Variable { + id: Any + type_expr: Any +} +py2puml.parsing.astvisitors.AssignedVariablesCollector *-- py2puml.parsing.astvisitors.Variable +py2puml.parsing.astvisitors.ConstructorVisitor *-- py2puml.parsing.moduleresolver.ModuleResolver +py2puml.parsing.astvisitors.ConstructorVisitor *-- py2puml.parsing.astvisitors.Variable +py2puml.parsing.astvisitors.SignatureVariablesCollector *-- py2puml.parsing.astvisitors.Variable +@enduml diff --git a/py2puml/py2puml.py b/py2puml/py2puml.py index 2522529..8300beb 100644 --- a/py2puml/py2puml.py +++ b/py2puml/py2puml.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Iterable +from typing import Dict, Iterable, List from py2puml.domain.umlitem import UmlItem from py2puml.domain.umlrelation import UmlRelation @@ -9,11 +9,6 @@ def py2puml(domain_path: str, domain_module: str) -> Iterable[str]: domain_items_by_fqn: Dict[str, UmlItem] = {} domain_relations: List[UmlRelation] = [] - inspect_package( - domain_path, - domain_module, - domain_items_by_fqn, - domain_relations - ) + inspect_package(domain_path, domain_module, domain_items_by_fqn, domain_relations) return to_puml_content(domain_module, domain_items_by_fqn.values(), domain_relations) diff --git a/py2puml/utils.py b/py2puml/utils.py index 76361c9..6d997e7 100644 --- a/py2puml/utils.py +++ b/py2puml/utils.py @@ -1,9 +1,10 @@ from typing import Type + def investigate_domain_definition(type_to_inspect: Type): - ''' + """ Utilitary function which inspects the annotations of the given type - ''' + """ type_annotations = getattr(type_to_inspect, '__annotations__', None) if type_annotations is None: # print(f'class {type_to_inspect.__module__}.{type_to_inspect.__name__} of type {type(type_to_inspect)} has no annotation') @@ -11,7 +12,7 @@ def investigate_domain_definition(type_to_inspect: Type): if attr_class_key != '__doc__': print( f'{type_to_inspect.__name__}.{attr_class_key}:', - getattr(type_to_inspect, attr_class_key) + getattr(type_to_inspect, attr_class_key), ) else: # print(type_to_inspect.__annotations__) @@ -20,5 +21,6 @@ def investigate_domain_definition(type_to_inspect: Type): if attr_class_key != '__doc__': print( f'{type_to_inspect.__name__}.{attr_name}:', - attr_class_key, getattr(attr_class, attr_class_key) + attr_class_key, + getattr(attr_class, attr_class_key), ) diff --git a/pyproject.toml b/pyproject.toml index 9fa3e8f..5944437 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ include = [ py2puml = 'py2puml.cli:run' [tool.poetry.dependencies] -python = "^3.8" +python = "^3.10" [tool.poetry.group.dev.dependencies] pylint = "^2.16.1" diff --git a/tests/__init__.py b/tests/__init__.py index 86d63ce..c792a01 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -2,7 +2,6 @@ from pathlib import Path from re import compile as re_compile - # exports the version and the project description read from the pyproject.toml file __version__ = None __description__ = None @@ -13,6 +12,7 @@ VERSION_PATTERN = re_compile('^version = "([^"]+)"$') DESCRIPTION_PATTERN = re_compile('^description = "([^"]+)"$') + def get_from_line_and_pattern(content_line: str, pattern) -> str: pattern_match = pattern.search(content_line) if pattern_match is None: @@ -20,7 +20,10 @@ def get_from_line_and_pattern(content_line: str, pattern) -> str: else: return pattern_match.group(1) + with open(PROJECT_PATH / 'pyproject.toml', encoding='utf8') as pyproject_file: for line in takewhile(lambda _: __version__ is None or __description__ is None, pyproject_file): __version__ = __version__ if __version__ is not None else get_from_line_and_pattern(line, VERSION_PATTERN) - __description__ = __description__ if __description__ is not None else get_from_line_and_pattern(line, DESCRIPTION_PATTERN) + __description__ = ( + __description__ if __description__ is not None else get_from_line_and_pattern(line, DESCRIPTION_PATTERN) + ) diff --git a/tests/asserts/attribute.py b/tests/asserts/attribute.py index 794ade2..bd560b6 100644 --- a/tests/asserts/attribute.py +++ b/tests/asserts/attribute.py @@ -1,7 +1,12 @@ - from py2puml.domain.umlclass import UmlAttribute -def assert_attribute(attribute: UmlAttribute, expected_name: str, expected_type: str, expected_staticity: bool): + +def assert_attribute( + attribute: UmlAttribute, + expected_name: str, + expected_type: str, + expected_staticity: bool, +): assert attribute.name == expected_name assert attribute.type == expected_type assert attribute.static == expected_staticity diff --git a/tests/asserts/method.py b/tests/asserts/method.py new file mode 100644 index 0000000..09c6fa6 --- /dev/null +++ b/tests/asserts/method.py @@ -0,0 +1,8 @@ +from py2puml.domain.umlclass import UmlMethod + + +def assert_method(method: UmlMethod, expected_name: str, expected_signature: str): + assert method.name == expected_name + assert method.signature == expected_signature + # TODO: add 'is_static' attribute to UmlMethod for static methods + # TODO: add 'is_class' attribute to UmlMethod for class methods diff --git a/tests/asserts/relation.py b/tests/asserts/relation.py index 81db46f..aa3505b 100644 --- a/tests/asserts/relation.py +++ b/tests/asserts/relation.py @@ -1,7 +1,7 @@ +from py2puml.domain.umlrelation import RelType, UmlRelation -from py2puml.domain.umlrelation import UmlRelation, RelType def assert_relation(uml_relation: UmlRelation, source_fqn: str, target_fqn: str, rel_type: RelType): assert uml_relation.source_fqn == source_fqn, 'source end' assert uml_relation.target_fqn == target_fqn, 'target end' - assert uml_relation.type == rel_type \ No newline at end of file + assert uml_relation.type == rel_type diff --git a/tests/asserts/variable.py b/tests/asserts/variable.py index 4e0e67d..0ba01f4 100644 --- a/tests/asserts/variable.py +++ b/tests/asserts/variable.py @@ -1,11 +1,11 @@ - from ast import get_source_segment -from py2puml.parsing.astvisitors import Variable +from py2puml.parsing.astvisitors import Argument + -def assert_Variable(variable: Variable, id: str, type_str: str, source_code: str): +def assert_Variable(variable: Argument, id: str, type_str: str, source_code: str): assert variable.id == id if type_str is None: assert variable.type_expr == None, 'no type annotation' else: - assert get_source_segment(source_code, variable.type_expr) == type_str \ No newline at end of file + assert get_source_segment(source_code, variable.type_expr) == type_str diff --git a/tests/modules/withabstract.py b/tests/modules/withabstract.py index 1015369..aac929f 100644 --- a/tests/modules/withabstract.py +++ b/tests/modules/withabstract.py @@ -4,7 +4,8 @@ class ClassTemplate(ABC): @abstractmethod def must_be_implemented(self): - """ Docstring.""" + """Docstring.""" + class ConcreteClass(ClassTemplate): def must_be_implemented(self): diff --git a/tests/modules/withbasictypes.py b/tests/modules/withbasictypes.py index c1e8ce7..681001e 100644 --- a/tests/modules/withbasictypes.py +++ b/tests/modules/withbasictypes.py @@ -1,4 +1,3 @@ - from dataclasses import dataclass diff --git a/tests/modules/withcomposition.py b/tests/modules/withcomposition.py index e882764..eff720a 100644 --- a/tests/modules/withcomposition.py +++ b/tests/modules/withcomposition.py @@ -1,4 +1,3 @@ - from dataclasses import dataclass from typing import List @@ -9,6 +8,7 @@ class Address: zipcode: str city: str + @dataclass class Worker: name: str @@ -18,6 +18,7 @@ class Worker: home_address: Address work_address: Address + @dataclass class Firm: name: str diff --git a/tests/modules/withcompoundtypewithdigits.py b/tests/modules/withcompoundtypewithdigits.py index f141e62..bb856a7 100644 --- a/tests/modules/withcompoundtypewithdigits.py +++ b/tests/modules/withcompoundtypewithdigits.py @@ -2,10 +2,12 @@ class IPv6: - '''class name with digits''' + """class name with digits""" + def __init__(self, address: str) -> None: self.address: str = address + class Multicast: def __init__(self, address: IPv6, repetition: int): # List[IPv6] must be parsed diff --git a/tests/modules/withconstructor.py b/tests/modules/withconstructor.py index 274fd79..2c3c6f9 100644 --- a/tests/modules/withconstructor.py +++ b/tests/modules/withconstructor.py @@ -2,15 +2,17 @@ from math import pi from typing import List, Tuple -from tests.modules import withenum from tests import modules +from tests.modules import withenum from tests.modules.withenum import TimeUnit + class Coordinates: def __init__(self, x: float, y: float): self.x = x self.y = y + class Point: PI: float = pi origin = Coordinates(0, 0) diff --git a/tests/modules/withenum.py b/tests/modules/withenum.py index 7d76b86..b38176e 100644 --- a/tests/modules/withenum.py +++ b/tests/modules/withenum.py @@ -1,7 +1,7 @@ - from enum import Enum + class TimeUnit(Enum): DAYS = 'd' HOURS = 'h' - MINUTE = 'm' \ No newline at end of file + MINUTE = 'm' diff --git a/tests/modules/withinheritancewithinmodule.py b/tests/modules/withinheritancewithinmodule.py index 995185f..2bb3084 100644 --- a/tests/modules/withinheritancewithinmodule.py +++ b/tests/modules/withinheritancewithinmodule.py @@ -5,14 +5,17 @@ class Animal: has_notochord: bool + @dataclass class Fish(Animal): fins_number: int + @dataclass class Light: luminosity_max: float + @dataclass class GlowingFish(Fish, Light): glow_for_hunting: bool diff --git a/tests/modules/withinheritedconstructor/metricorigin.py b/tests/modules/withinheritedconstructor/metricorigin.py index 5199d1c..c854a51 100644 --- a/tests/modules/withinheritedconstructor/metricorigin.py +++ b/tests/modules/withinheritedconstructor/metricorigin.py @@ -1,4 +1,5 @@ from .point import Origin + class MetricOrigin(Origin): unit: str = 'm' diff --git a/tests/modules/withinheritedconstructor/point.py b/tests/modules/withinheritedconstructor/point.py index 66609aa..2421875 100644 --- a/tests/modules/withinheritedconstructor/point.py +++ b/tests/modules/withinheritedconstructor/point.py @@ -1,8 +1,8 @@ - class Point: - def __init__(self, x: float=0, y: float=0): + def __init__(self, x: float = 0, y: float = 0): self.x = x self.y = y + class Origin(Point): is_origin: bool = True diff --git a/tests/modules/withmethods/withinheritedmethods.py b/tests/modules/withmethods/withinheritedmethods.py new file mode 100644 index 0000000..e19e241 --- /dev/null +++ b/tests/modules/withmethods/withinheritedmethods.py @@ -0,0 +1,13 @@ +from .withmethods import Point + + +class ThreeDimensionalPoint(Point): + def __init__(self, x: int, y: str, z: float): + super().__init__(x=x, y=y) + self.z = z + + def move(self, offset: int): + self.x += offset + + def check_positive(self) -> bool: + return self.x > 0 diff --git a/tests/modules/withmethods/withmethods.py b/tests/modules/withmethods/withmethods.py new file mode 100644 index 0000000..3bb2e87 --- /dev/null +++ b/tests/modules/withmethods/withmethods.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from math import pi +from typing import Tuple + +from tests import modules +from tests.modules import withenum +from tests.modules.withenum import TimeUnit + + +class Coordinates: + def __init__(self, x: float, y: float) -> None: + self.x = x + self.y = y + + +class Point: + PI: float = pi + origin = Coordinates(0, 0) + + @staticmethod + def from_values(x: int, y: str) -> Point: + return Point(x, y) + + def get_coordinates(self) -> Tuple[float, str]: + return self.x, self.y + + def __init__(self, x: int, y: Tuple[bool]) -> None: + self.coordinates: Coordinates = Coordinates(x, float(y)) + self.day_unit: withenum.TimeUnit = withenum.TimeUnit.DAYS + self.hour_unit: modules.withenum.TimeUnit = modules.withenum.TimeUnit.HOURS + self.time_resolution: Tuple[str, withenum.TimeUnit] = 'minute', TimeUnit.MINUTE + self.x = x + self.y = y + + def do_something(self, posarg_nohint, posarg_hint: str, posarg_default=3) -> int: + return 44 diff --git a/tests/modules/withnamedtuple.py b/tests/modules/withnamedtuple.py index e64f8d9..a20e628 100644 --- a/tests/modules/withnamedtuple.py +++ b/tests/modules/withnamedtuple.py @@ -1,4 +1,3 @@ - from collections import namedtuple Circle = namedtuple('Circle', ['x', 'y', 'radius'], defaults=[1]) diff --git a/tests/modules/withnestednamespace/branches/branch.py b/tests/modules/withnestednamespace/branches/branch.py index 8cb84ae..b7b4cc3 100644 --- a/tests/modules/withnestednamespace/branches/branch.py +++ b/tests/modules/withnestednamespace/branches/branch.py @@ -3,10 +3,12 @@ from ..nomoduleroot.modulechild.leaf import OakLeaf + @dataclass class Branch: length: float + @dataclass class OakBranch(Branch): sub_branches: List['OakBranch'] diff --git a/tests/modules/withnestednamespace/nomoduleroot/modulechild/leaf.py b/tests/modules/withnestednamespace/nomoduleroot/modulechild/leaf.py index 4f36d87..ea3ab27 100644 --- a/tests/modules/withnestednamespace/nomoduleroot/modulechild/leaf.py +++ b/tests/modules/withnestednamespace/nomoduleroot/modulechild/leaf.py @@ -1,14 +1,17 @@ from dataclasses import dataclass + @dataclass class CommownLeaf: color: int area: float + @dataclass class PineLeaf(CommownLeaf): length: float + @dataclass class OakLeaf(CommownLeaf): - curves: int \ No newline at end of file + curves: int diff --git a/tests/modules/withnestednamespace/tests.modules.withnestednamespace.puml b/tests/modules/withnestednamespace/tests.modules.withnestednamespace.puml index a267c19..fd02e9f 100644 --- a/tests/modules/withnestednamespace/tests.modules.withnestednamespace.puml +++ b/tests/modules/withnestednamespace/tests.modules.withnestednamespace.puml @@ -53,4 +53,3 @@ tests.modules.withnestednamespace.tree.Tree *-- tests.modules.withnestednamespac tests.modules.withnestednamespace.tree.Tree *-- tests.modules.withnestednamespace.withonlyonesubpackage.underground.Soil footer Generated by //py2puml// @enduml - diff --git a/tests/modules/withnestednamespace/tree.py b/tests/modules/withnestednamespace/tree.py index 8c9db8d..2c836b8 100644 --- a/tests/modules/withnestednamespace/tree.py +++ b/tests/modules/withnestednamespace/tree.py @@ -1,11 +1,12 @@ from dataclasses import dataclass from typing import List -from .trunks.trunk import Trunk from .branches.branch import OakBranch +from .trunks.trunk import Trunk from .withonlyonesubpackage.underground import Soil from .withonlyonesubpackage.underground.roots.roots import Roots + @dataclass class Tree: height: float @@ -13,6 +14,7 @@ class Tree: roots: Roots soil: Soil + @dataclass class Oak(Tree): trunk: Trunk diff --git a/tests/modules/withnestednamespace/trunks/trunk.py b/tests/modules/withnestednamespace/trunks/trunk.py index 60dc5d0..77aa6e8 100644 --- a/tests/modules/withnestednamespace/trunks/trunk.py +++ b/tests/modules/withnestednamespace/trunks/trunk.py @@ -1,5 +1,6 @@ from dataclasses import dataclass + @dataclass class Trunk: height: float diff --git a/tests/modules/withnestednamespace/withonlyonesubpackage/underground/__init__.py b/tests/modules/withnestednamespace/withonlyonesubpackage/underground/__init__.py index 36c56ca..98596d3 100644 --- a/tests/modules/withnestednamespace/withonlyonesubpackage/underground/__init__.py +++ b/tests/modules/withnestednamespace/withonlyonesubpackage/underground/__init__.py @@ -1,5 +1,6 @@ from dataclasses import dataclass + @dataclass class Soil: humidity: float diff --git a/tests/modules/withnestednamespace/withonlyonesubpackage/underground/roots/roots.py b/tests/modules/withnestednamespace/withonlyonesubpackage/underground/roots/roots.py index 3e66df8..03eeb44 100644 --- a/tests/modules/withnestednamespace/withonlyonesubpackage/underground/roots/roots.py +++ b/tests/modules/withnestednamespace/withonlyonesubpackage/underground/roots/roots.py @@ -1,5 +1,6 @@ from dataclasses import dataclass + @dataclass class Roots: mass: float diff --git a/tests/modules/withsubdomain/subdomain/insubdomain.py b/tests/modules/withsubdomain/subdomain/insubdomain.py index 4b660d8..0eacf53 100644 --- a/tests/modules/withsubdomain/subdomain/insubdomain.py +++ b/tests/modules/withsubdomain/subdomain/insubdomain.py @@ -1,13 +1,15 @@ - from dataclasses import dataclass + def horsepower_to_kilowatt(horsepower: float) -> float: return horsepower * 745.7 + @dataclass class Engine: horsepower: int + @dataclass class Pilot: name: str diff --git a/tests/modules/withsubdomain/withsubdomain.py b/tests/modules/withsubdomain/withsubdomain.py index 5da54e2..14d206a 100644 --- a/tests/modules/withsubdomain/withsubdomain.py +++ b/tests/modules/withsubdomain/withsubdomain.py @@ -1,8 +1,8 @@ - from dataclasses import dataclass # the import of the horsepower_to_kilowatt function should not break the parsing -from tests.modules.withsubdomain.subdomain.insubdomain import Engine, horsepower_to_kilowatt +from tests.modules.withsubdomain.subdomain.insubdomain import Engine + @dataclass class Car: diff --git a/tests/modules/withwrappedconstructor.py b/tests/modules/withwrappedconstructor.py index 2787899..d4537f8 100644 --- a/tests/modules/withwrappedconstructor.py +++ b/tests/modules/withwrappedconstructor.py @@ -1,5 +1,6 @@ from functools import wraps + def count_signature_args(func): @wraps(func) def signature_args_counter(*args, **kwargs): @@ -9,27 +10,35 @@ def signature_args_counter(*args, **kwargs): return signature_args_counter + def signature_arg_values(func): @wraps(func) def signature_values_lister(*args, **kwargs): print('positional arguments:', ', '.join(str(arg) for arg in args)) - print('keyword arguments:', ', '.join(f'{key}: {value}' for key, value in kwargs.items())) + print( + 'keyword arguments:', + ', '.join(f'{key}: {value}' for key, value in kwargs.items()), + ) func(*args, **kwargs) return signature_values_lister + class Point: - ''' + """ A Point class, with a constructor which is decorated by wrapping decorators - ''' + """ + @count_signature_args @signature_arg_values def __init__(self, x: float, y: float): self.x = x self.y = y + # Point(2.5, y=3.2) + def signature_improper_decorator(func): def not_wrapping_decorator(*args, **kwargs): print(f'{len(args) + len(kwargs)} arguments') @@ -37,13 +46,16 @@ def not_wrapping_decorator(*args, **kwargs): return not_wrapping_decorator + class PointDecoratedWithoutWrapping: - ''' + """ A Point class, with a constructor which is decorated by wrapping decorators - ''' + """ + @signature_improper_decorator def __init__(self, x: float, y: float): self.x = x self.y = y -# PointDecoratedWithoutWrapping(2.5, y=3.2) \ No newline at end of file + +# PointDecoratedWithoutWrapping(2.5, y=3.2) diff --git a/tests/puml_files/with_methods.puml b/tests/puml_files/with_methods.puml new file mode 100644 index 0000000..9a225a0 --- /dev/null +++ b/tests/puml_files/with_methods.puml @@ -0,0 +1,33 @@ +@startuml tests.modules.withmethods +namespace tests.modules.withmethods { + namespace withmethods {} + namespace withinheritedmethods {} +} +class tests.modules.withmethods.withmethods.Point { + PI: float {static} + coordinates: Coordinates + day_unit: TimeUnit + hour_unit: TimeUnit + time_resolution: Tuple[str, TimeUnit] + x: int + y: Tuple[bool] + {static} Point from_values(int x, str y) + Tuple[float, str] get_coordinates(self) + __init__(self, int x, Tuple[bool] y) + int do_something(self, posarg_nohint, str posarg_hint, posarg_default) +} +class tests.modules.withmethods.withinheritedmethods.ThreeDimensionalPoint { + z: float + __init__(self, int x, str y, float z) + move(self, int offset) + bool check_positive(self) +} +class tests.modules.withmethods.withmethods.Coordinates { + x: float + y: float + __init__(self, float x, float y) +} +tests.modules.withmethods.withmethods.Point *-- tests.modules.withmethods.withmethods.Coordinates +tests.modules.withmethods.withmethods.Point <|-- tests.modules.withmethods.withinheritedmethods.ThreeDimensionalPoint +footer Generated by //py2puml// +@enduml diff --git a/tests/py2puml/conftest.py b/tests/py2puml/conftest.py index 0a044ea..89f3401 100644 --- a/tests/py2puml/conftest.py +++ b/tests/py2puml/conftest.py @@ -1,6 +1,6 @@ -''' +""" Fixtures that can be used by the automated unit tests. -''' +""" from typing import Dict, List @@ -9,10 +9,12 @@ from py2puml.domain.umlitem import UmlItem from py2puml.domain.umlrelation import UmlRelation + @fixture(scope='function') def domain_items_by_fqn() -> Dict[str, UmlItem]: return {} + @fixture(scope='function') def domain_relations() -> List[UmlRelation]: return [] diff --git a/tests/py2puml/export/test_namespace.py b/tests/py2puml/export/test_namespace.py index 23ea139..e7c5f76 100644 --- a/tests/py2puml/export/test_namespace.py +++ b/tests/py2puml/export/test_namespace.py @@ -9,11 +9,14 @@ from py2puml.inspection.inspectpackage import inspect_package -@mark.parametrize(['root_package', 'module_qualified_name'], [ - (Package(None), 'py2puml'), - (Package(None, [Package('py2puml')]), 'py2puml'), - (Package(None), 'py2puml.export.namespace'), -]) +@mark.parametrize( + ['root_package', 'module_qualified_name'], + [ + (Package(None), 'py2puml'), + (Package(None, [Package('py2puml')]), 'py2puml'), + (Package(None), 'py2puml.export.namespace'), + ], +) def test_get_or_create_module_package(root_package: Package, module_qualified_name: str): module_parts = module_qualified_name.split('.') module_package = get_or_create_module_package(root_package, module_parts) @@ -23,25 +26,34 @@ def test_get_or_create_module_package(root_package: Package, module_qualified_na inner_package = root_package for module_name in module_parts: inner_package = next( - child_package for child_package in inner_package.children - if child_package.name == module_name + child_package for child_package in inner_package.children if child_package.name == module_name ) assert inner_package == module_package, f'the module package is contained in the {module_qualified_name} hierarchy' -@mark.parametrize(['uml_items', 'items_number_by_package_name'], [ - ([UmlItem('Package', 'py2puml.export.namespace.Package')], {'namespace': 1}), - ([ - UmlItem('UmlClass', 'py2puml.domain.umlclass.UmlClass'), - UmlItem('UmlAttribute', 'py2puml.domain.umlclass.UmlAttribute') - ], {'umlclass': 2}), - ([ - UmlItem('Package', 'py2puml.export.namespace.Package'), - UmlItem('ImaginaryClass', 'py2puml.domain.ImaginaryClass'), - UmlItem('UmlClass', 'py2puml.domain.umlclass.UmlClass'), - UmlItem('UmlAttribute', 'py2puml.domain.umlclass.UmlAttribute') - ], {'domain': 1, 'namespace': 1, 'umlclass': 2}), -]) + +@mark.parametrize( + ['uml_items', 'items_number_by_package_name'], + [ + ([UmlItem('Package', 'py2puml.export.namespace.Package')], {'namespace': 1}), + ( + [ + UmlItem('UmlClass', 'py2puml.domain.umlclass.UmlClass'), + UmlItem('UmlAttribute', 'py2puml.domain.umlclass.UmlAttribute'), + ], + {'umlclass': 2}, + ), + ( + [ + UmlItem('Package', 'py2puml.export.namespace.Package'), + UmlItem('ImaginaryClass', 'py2puml.domain.ImaginaryClass'), + UmlItem('UmlClass', 'py2puml.domain.umlclass.UmlClass'), + UmlItem('UmlAttribute', 'py2puml.domain.umlclass.UmlAttribute'), + ], + {'domain': 1, 'namespace': 1, 'umlclass': 2}, + ), + ], +) def test_build_packages_structure(uml_items: List[UmlItem], items_number_by_package_name: Dict[str, int]): root_package = build_packages_structure(uml_items) @@ -53,59 +65,105 @@ def test_build_packages_structure(uml_items: List[UmlItem], items_number_by_pack module_parts = uml_item.fqn.split('.')[:-1] for module_name in module_parts: inner_package = next( - child_package for child_package in inner_package.children - if child_package.name == module_name + child_package for child_package in inner_package.children if child_package.name == module_name ) expected_items_number = items_number_by_package_name.get(module_name, 0) - assert expected_items_number == inner_package.items_number, f'package {module_name} must have {expected_items_number} items, found {inner_package.items_number}' + assert ( + expected_items_number == inner_package.items_number + ), f'package {module_name} must have {expected_items_number} items, found {inner_package.items_number}' has_children_packages = len(inner_package.children) > 0 - has_module_items = inner_package.items_number > 0 - assert has_children_packages or has_module_items, f'package {inner_package.name} in hierarchy of {module_parts} has items or children packages' + has_module_items = inner_package.items_number > 0 + assert ( + has_children_packages or has_module_items + ), f'package {inner_package.name} in hierarchy of {module_parts} has items or children packages' NO_CHILDREN_PACKAGES = [] -SAMPLE_ROOT_PACKAGE = Package(None, [ - Package('py2puml',[ - Package('domain', [ - Package('package', NO_CHILDREN_PACKAGES, 1), - Package('umlclass', NO_CHILDREN_PACKAGES, 1) - ]), - Package('inspection', [ - Package('inspectclass', NO_CHILDREN_PACKAGES, 1) - ]) - ]) -]) -SAMPLE_NAMESPACE_LINES = '''namespace py2puml { +SAMPLE_ROOT_PACKAGE = Package( + None, + [ + Package( + 'py2puml', + [ + Package( + 'domain', + [ + Package('package', NO_CHILDREN_PACKAGES, 1), + Package('umlclass', NO_CHILDREN_PACKAGES, 1), + ], + ), + Package('inspection', [Package('inspectclass', NO_CHILDREN_PACKAGES, 1)]), + ], + ) + ], +) +SAMPLE_NAMESPACE_LINES = """namespace py2puml { namespace domain { namespace package {} namespace umlclass {} } namespace inspection.inspectclass {} -}''' +}""" + @mark.parametrize( - ['package_to_visit', 'parent_namespace_names', 'indentation_level', 'expected_namespace_lines'], [ - (Package(None), tuple(), 0, []), # the root package yields no namespace documentation - (Package(None, NO_CHILDREN_PACKAGES, 1), tuple(), 0, ['namespace {}\n']), # the root package yields namespace documentation if it has uml items - (Package(None, NO_CHILDREN_PACKAGES, 1), tuple(), 1, [' namespace {}\n']), # indentation level of 1 -> 2 spaces - (Package(None, NO_CHILDREN_PACKAGES, 1), tuple(), 3, [' namespace {}\n']), # indentation level of 3 -> 6 spaces - (Package('umlclass', NO_CHILDREN_PACKAGES, 2), ('py2puml', 'domain'), 0, ['namespace py2puml.domain.umlclass {}\n']), - (SAMPLE_ROOT_PACKAGE, tuple(), 0, (f'{line}\n' for line in SAMPLE_NAMESPACE_LINES.split('\n'))), - ] + 'package_to_visit', + 'parent_namespace_names', + 'indentation_level', + 'expected_namespace_lines', + ], + [ + ( + Package(None), + tuple(), + 0, + [], + ), # the root package yields no namespace documentation + ( + Package(None, NO_CHILDREN_PACKAGES, 1), + tuple(), + 0, + ['namespace {}\n'], + ), # the root package yields namespace documentation if it has uml items + ( + Package(None, NO_CHILDREN_PACKAGES, 1), + tuple(), + 1, + [' namespace {}\n'], + ), # indentation level of 1 -> 2 spaces + ( + Package(None, NO_CHILDREN_PACKAGES, 1), + tuple(), + 3, + [' namespace {}\n'], + ), # indentation level of 3 -> 6 spaces + ( + Package('umlclass', NO_CHILDREN_PACKAGES, 2), + ('py2puml', 'domain'), + 0, + ['namespace py2puml.domain.umlclass {}\n'], + ), + ( + SAMPLE_ROOT_PACKAGE, + tuple(), + 0, + (f'{line}\n' for line in SAMPLE_NAMESPACE_LINES.split('\n')), + ), + ], ) def test_visit_package( package_to_visit: Package, parent_namespace_names: Tuple[str], indentation_level: int, - expected_namespace_lines: List[str] + expected_namespace_lines: List[str], ): for expected_namespace_line, namespace_line in zip( expected_namespace_lines, - visit_package(package_to_visit, parent_namespace_names, indentation_level) + visit_package(package_to_visit, parent_namespace_names, indentation_level), ): assert expected_namespace_line == namespace_line @@ -119,5 +177,7 @@ def test_build_packages_structure_visit_package_from_tree_package( package = build_packages_structure(domain_items_by_fqn.values()) with open(f'{domain_path}/plantuml_namespace.txt', encoding='utf8') as tree_namespace_file: - for line_index, (namespace_line, expected_namespace_line) in enumerate(zip(visit_package(package, tuple(), 0), tree_namespace_file)): + for line_index, (namespace_line, expected_namespace_line) in enumerate( + zip(visit_package(package, tuple(), 0), tree_namespace_file) + ): assert namespace_line == expected_namespace_line, f'{line_index}: namespace content' diff --git a/tests/py2puml/inspection/test_inspectclass.py b/tests/py2puml/inspection/test_inspectclass.py index 43a6e03..b6c4f7c 100644 --- a/tests/py2puml/inspection/test_inspectclass.py +++ b/tests/py2puml/inspection/test_inspectclass.py @@ -1,9 +1,9 @@ -from typing import Dict, List from importlib import import_module +from typing import Dict, List +from py2puml.domain.umlclass import UmlAttribute, UmlClass from py2puml.domain.umlitem import UmlItem -from py2puml.domain.umlclass import UmlClass, UmlAttribute -from py2puml.domain.umlrelation import UmlRelation, RelType +from py2puml.domain.umlrelation import RelType, UmlRelation from py2puml.inspection.inspectmodule import inspect_module from tests.asserts.attribute import assert_attribute @@ -16,7 +16,8 @@ def test_inspect_module_should_find_static_and_instance_attributes( inspect_module( import_module('tests.modules.withconstructor'), 'tests.modules.withconstructor', - domain_items_by_fqn, domain_relations + domain_items_by_fqn, + domain_relations, ) assert len(domain_items_by_fqn) == 2, 'two classes must be inspected' @@ -48,14 +49,21 @@ def test_inspect_module_should_find_static_and_instance_attributes( 'dates': ('List[date]', False), } assert len(point_umlitem.attributes) == len(point_expected_attributes), 'all Point attributes must be verified' - for attribute_name, (atrribute_type, attribute_staticity) in point_expected_attributes.items(): - point_attribute: UmlAttribute = next(( - attribute - for attribute in point_umlitem.attributes - if attribute.name == attribute_name - ), None) + for attribute_name, ( + atrribute_type, + attribute_staticity, + ) in point_expected_attributes.items(): + point_attribute: UmlAttribute = next( + (attribute for attribute in point_umlitem.attributes if attribute.name == attribute_name), + None, + ) assert point_attribute is not None, f'attribute {attribute_name} must be detected' - assert_attribute(point_attribute, attribute_name, atrribute_type, expected_staticity=attribute_staticity) + assert_attribute( + point_attribute, + attribute_name, + atrribute_type, + expected_staticity=attribute_staticity, + ) # Coordinates is a component of Point assert len(domain_relations) == 1, '1 composition' @@ -63,16 +71,18 @@ def test_inspect_module_should_find_static_and_instance_attributes( domain_relations[0], 'tests.modules.withconstructor.Point', 'tests.modules.withconstructor.Coordinates', - RelType.COMPOSITION + RelType.COMPOSITION, ) + def test_inspect_module_should_find_abstract_class( domain_items_by_fqn: Dict[str, UmlItem], domain_relations: List[UmlRelation] ): inspect_module( import_module('tests.modules.withabstract'), 'tests.modules.withabstract', - domain_items_by_fqn, domain_relations + domain_items_by_fqn, + domain_relations, ) assert len(domain_items_by_fqn) == 2, 'two classes must be inspected' @@ -87,6 +97,7 @@ def test_inspect_module_should_find_abstract_class( assert domain_relations[0].source_fqn == 'tests.modules.withabstract.ClassTemplate' assert domain_relations[0].target_fqn == 'tests.modules.withabstract.ConcreteClass' + def test_inspect_module_parse_class_constructor_should_not_process_inherited_constructor( domain_items_by_fqn: Dict[str, UmlItem], domain_relations: List[UmlRelation] ): @@ -94,12 +105,14 @@ def test_inspect_module_parse_class_constructor_should_not_process_inherited_con inspect_module( import_module('tests.modules.withinheritedconstructor.point'), 'tests.modules.withinheritedconstructor.point', - domain_items_by_fqn, domain_relations + domain_items_by_fqn, + domain_relations, ) inspect_module( import_module('tests.modules.withinheritedconstructor.metricorigin'), 'tests.modules.withinheritedconstructor.metricorigin', - domain_items_by_fqn, domain_relations + domain_items_by_fqn, + domain_relations, ) assert len(domain_items_by_fqn) == 3, 'three classes must be inspected' @@ -118,18 +131,22 @@ def test_inspect_module_parse_class_constructor_should_not_process_inherited_con assert_attribute(is_origin_attribute, 'is_origin', 'bool', expected_staticity=True) # MetricOrigin UmlClass - metric_origin_umlitem: UmlClass = domain_items_by_fqn['tests.modules.withinheritedconstructor.metricorigin.MetricOrigin'] + metric_origin_umlitem: UmlClass = domain_items_by_fqn[ + 'tests.modules.withinheritedconstructor.metricorigin.MetricOrigin' + ] assert len(metric_origin_umlitem.attributes) == 1, '1 attribute of MetricOrigin must be inspected' unit_attribute = metric_origin_umlitem.attributes[0] assert_attribute(unit_attribute, 'unit', 'str', expected_staticity=True) + def test_inspect_module_should_unwrap_decorated_constructor( domain_items_by_fqn: Dict[str, UmlItem], domain_relations: List[UmlRelation] ): inspect_module( import_module('tests.modules.withwrappedconstructor'), 'tests.modules.withwrappedconstructor', - domain_items_by_fqn, domain_relations + domain_items_by_fqn, + domain_relations, ) assert len(domain_items_by_fqn) == 2, 'two classes must be inspected' @@ -142,17 +159,19 @@ def test_inspect_module_should_unwrap_decorated_constructor( assert_attribute(y_attribute, 'y', 'float', expected_staticity=False) # PointDecoratedWithoutWrapping UmlClass - point_without_wrapping_umlitem: UmlClass = domain_items_by_fqn['tests.modules.withwrappedconstructor.PointDecoratedWithoutWrapping'] - assert len(point_without_wrapping_umlitem.attributes) == 0, 'the attributes of the original constructor could not be found, the constructor was not wrapped by the decorator' + point_without_wrapping_umlitem: UmlClass = domain_items_by_fqn[ + 'tests.modules.withwrappedconstructor.PointDecoratedWithoutWrapping' + ] + assert ( + len(point_without_wrapping_umlitem.attributes) == 0 + ), 'the attributes of the original constructor could not be found, the constructor was not wrapped by the decorator' + def test_inspect_module_should_handle_compound_types_with_numbers_in_their_name( domain_items_by_fqn: Dict[str, UmlItem], domain_relations: List[UmlRelation] ): fqdn = 'tests.modules.withcompoundtypewithdigits' - inspect_module( - import_module(fqdn), fqdn, - domain_items_by_fqn, domain_relations - ) + inspect_module(import_module(fqdn), fqdn, domain_items_by_fqn, domain_relations) assert len(domain_items_by_fqn) == 2, 'two classes must be inspected' @@ -167,3 +186,58 @@ def test_inspect_module_should_handle_compound_types_with_numbers_in_their_name( assert len(multicast_umlitem.attributes) == 1, '1 attributes of Multicast must be inspected' address_attribute = multicast_umlitem.attributes[0] assert_attribute(address_attribute, 'addresses', 'List[IPv6]', expected_staticity=False) + + +def test_inspect_module_should_find_methods( + domain_items_by_fqn: Dict[str, UmlItem], domain_relations: List[UmlRelation] +): + """ + Test that methods are detected including static methods + """ + + inspect_module( + import_module('tests.modules.withmethods.withmethods'), + 'tests.modules.withmethods.withmethods', + domain_items_by_fqn, + domain_relations, + ) + + # Coordinates UmlClass + coordinates_umlitem: UmlClass = domain_items_by_fqn['tests.modules.withmethods.withmethods.Coordinates'] + assert len(coordinates_umlitem.methods) == 1 + + # Point UmlClass + point_umlitem: UmlClass = domain_items_by_fqn['tests.modules.withmethods.withmethods.Point'] + assert len(point_umlitem.methods) == 4 + + assert point_umlitem.methods[0].name == 'from_values' + assert point_umlitem.methods[1].name == 'get_coordinates' + assert point_umlitem.methods[2].name == '__init__' + assert point_umlitem.methods[3].name == 'do_something' + # FIXME: use 'assert_method' once UmlMethod restructured + + +def test_inspect_module_inherited_methods(domain_items_by_fqn: Dict[str, UmlItem], domain_relations: List[UmlRelation]): + """ + Test that inherited methods are not included in subclasses + """ + + inspect_module( + import_module('tests.modules.withmethods.withinheritedmethods'), + 'tests.modules.withmethods.withinheritedmethods', + domain_items_by_fqn, + domain_relations, + ) + + # ThreeDimensionalCoordinates UmlClass + coordinates_3d_umlitem: UmlClass = domain_items_by_fqn[ + 'tests.modules.withmethods.withinheritedmethods.ThreeDimensionalPoint' + ] + + # FIXME inherited methods should not be mentionned + assert len(coordinates_3d_umlitem.methods) == 3 + + assert coordinates_3d_umlitem.methods[2].name == 'check_positive' + assert coordinates_3d_umlitem.methods[0].name == '__init__' + assert coordinates_3d_umlitem.methods[1].name == 'move' + # FIXME: use 'assert_method' once UmlMethod restructured diff --git a/tests/py2puml/inspection/test_inspectdataclass.py b/tests/py2puml/inspection/test_inspectdataclass.py index c9c9b0d..70539c3 100644 --- a/tests/py2puml/inspection/test_inspectdataclass.py +++ b/tests/py2puml/inspection/test_inspectdataclass.py @@ -1,10 +1,9 @@ +from typing import Dict, List -from typing import Dict, List, Tuple - -from py2puml.inspection.inspectmodule import inspect_domain_definition +from py2puml.domain.umlclass import UmlClass from py2puml.domain.umlitem import UmlItem -from py2puml.domain.umlclass import UmlClass, UmlAttribute -from py2puml.domain.umlrelation import UmlRelation, RelType +from py2puml.domain.umlrelation import RelType, UmlRelation +from py2puml.inspection.inspectmodule import inspect_domain_definition from tests.asserts.attribute import assert_attribute from tests.asserts.relation import assert_relation @@ -48,21 +47,26 @@ def test_inspect_domain_definition_single_class_with_composition(): domain_relations[0], 'tests.modules.withcomposition.Worker', 'tests.modules.withcomposition.Worker', - RelType.COMPOSITION + RelType.COMPOSITION, ) # adress of worker assert_relation( domain_relations[1], 'tests.modules.withcomposition.Worker', 'tests.modules.withcomposition.Address', - RelType.COMPOSITION + RelType.COMPOSITION, ) def test_parse_inheritance_within_module(): domain_items_by_fqn: Dict[str, UmlItem] = {} domain_relations: List[UmlRelation] = [] - inspect_domain_definition(GlowingFish, 'tests.modules.withinheritancewithinmodule', domain_items_by_fqn, domain_relations) + inspect_domain_definition( + GlowingFish, + 'tests.modules.withinheritancewithinmodule', + domain_items_by_fqn, + domain_relations, + ) umlitems_by_fqn = list(domain_items_by_fqn.values()) assert len(umlitems_by_fqn) == 1, 'the class with multiple inheritance was inspected' @@ -70,8 +74,18 @@ def test_parse_inheritance_within_module(): assert child_glowing_fish.name == 'GlowingFish' assert child_glowing_fish.fqn == 'tests.modules.withinheritancewithinmodule.GlowingFish' assert len(child_glowing_fish.attributes) == 2 - assert_attribute(child_glowing_fish.attributes[0], 'glow_for_hunting', 'bool', expected_staticity=False) - assert_attribute(child_glowing_fish.attributes[1], 'glow_for_mating', 'bool', expected_staticity=False) + assert_attribute( + child_glowing_fish.attributes[0], + 'glow_for_hunting', + 'bool', + expected_staticity=False, + ) + assert_attribute( + child_glowing_fish.attributes[1], + 'glow_for_mating', + 'bool', + expected_staticity=False, + ) assert len(domain_relations) == 2, '2 inheritance relations must be inspected' parent_fish, parent_light = domain_relations @@ -80,11 +94,11 @@ def test_parse_inheritance_within_module(): parent_fish, 'tests.modules.withinheritancewithinmodule.Fish', 'tests.modules.withinheritancewithinmodule.GlowingFish', - RelType.INHERITANCE + RelType.INHERITANCE, ) assert_relation( parent_light, 'tests.modules.withinheritancewithinmodule.Light', 'tests.modules.withinheritancewithinmodule.GlowingFish', - RelType.INHERITANCE + RelType.INHERITANCE, ) diff --git a/tests/py2puml/inspection/test_inspectenum.py b/tests/py2puml/inspection/test_inspectenum.py index 6770b40..b8cb3b5 100644 --- a/tests/py2puml/inspection/test_inspectenum.py +++ b/tests/py2puml/inspection/test_inspectenum.py @@ -1,17 +1,18 @@ - from typing import Dict, List -from py2puml.domain.umlenum import UmlEnum, Member +from py2puml.domain.umlenum import Member, UmlEnum from py2puml.domain.umlitem import UmlItem from py2puml.domain.umlrelation import UmlRelation from py2puml.inspection.inspectmodule import inspect_domain_definition from tests.modules.withenum import TimeUnit + def assert_member(member: Member, expected_name: str, expected_value: str): assert member.name == expected_name assert member.value == expected_value + def test_inspect_enum_type(): domain_items_by_fqn: Dict[str, UmlItem] = {} domain_relations: List[UmlRelation] = [] diff --git a/tests/py2puml/inspection/test_inspectnamedtuple.py b/tests/py2puml/inspection/test_inspectnamedtuple.py index 1951cc2..d00b957 100644 --- a/tests/py2puml/inspection/test_inspectnamedtuple.py +++ b/tests/py2puml/inspection/test_inspectnamedtuple.py @@ -1,12 +1,13 @@ - from typing import Dict, List -from py2puml.domain.umlitem import UmlItem + from py2puml.domain.umlclass import UmlClass +from py2puml.domain.umlitem import UmlItem from py2puml.domain.umlrelation import UmlRelation from py2puml.inspection.inspectmodule import inspect_domain_definition -from tests.modules.withnamedtuple import Circle from tests.asserts.attribute import assert_attribute +from tests.modules.withnamedtuple import Circle + def test_parse_namedtupled_class(): domain_items_by_fqn: Dict[str, UmlItem] = {} diff --git a/tests/py2puml/parsing/mockedinstance.py b/tests/py2puml/parsing/mockedinstance.py index f2f09b0..2b76c77 100644 --- a/tests/py2puml/parsing/mockedinstance.py +++ b/tests/py2puml/parsing/mockedinstance.py @@ -1,12 +1,13 @@ -from json import dumps, JSONEncoder +from json import JSONEncoder, dumps from typing import _GenericAlias class MockedInstance: - ''' + """ Creates an object instance from a dictionary so that access paths like dict['key1']['key2']['key3'] can be replaced by instance.key1.key2.key3 - ''' + """ + def __init__(self, inner_attributes_as_dict: dict): self.update_instance_dict(self, inner_attributes_as_dict) @@ -15,7 +16,7 @@ def update_instance_dict(self, instance, attributes_dict: dict): for instance_attribute, value in attributes_dict.items(): if isinstance(value, dict): setattr(instance, instance_attribute, MockedInstance(value)) - + def __repr__(self): return dumps(self.__dict__, cls=MockedInstanceEncoder) diff --git a/tests/py2puml/parsing/test_astvisitors.py b/tests/py2puml/parsing/test_astvisitors.py index 8b2c70f..2528012 100644 --- a/tests/py2puml/parsing/test_astvisitors.py +++ b/tests/py2puml/parsing/test_astvisitors.py @@ -1,13 +1,17 @@ - -from typing import Dict, Tuple, List - -from ast import parse, AST, get_source_segment +import unittest +from ast import AST, get_source_segment, parse from inspect import getsource from textwrap import dedent +from typing import Dict, List, Tuple from pytest import mark -from py2puml.parsing.astvisitors import AssignedVariablesCollector, SignatureVariablesCollector, Variable, shorten_compound_type_annotation +from py2puml.parsing.astvisitors import ( + AssignedVariablesCollector, + SignatureArgumentsCollector, + TypeVisitor, + shorten_compound_type_annotation, +) from py2puml.parsing.moduleresolver import ModuleResolver from tests.asserts.variable import assert_Variable @@ -19,32 +23,44 @@ def __init__( # the reference to the instance is often 'self' by convention, but can be anything else me, # some arguments, typed or untyped - an_int: int, an_untyped, a_compound_type: Tuple[float, Dict[str, List[bool]]], + an_int: int, + an_untyped, + a_compound_type: Tuple[float, Dict[str, List[bool]]], # an argument with a default value - a_default_string: str='text', + a_default_string: str = 'text', # positional and keyword wildcard arguments - *args, **kwargs + *args, + **kwargs, ): pass + def test_SignatureVariablesCollector_collect_arguments(): constructor_source: str = dedent(getsource(ParseMyConstructorArguments.__init__.__code__)) constructor_ast: AST = parse(constructor_source) - collector = SignatureVariablesCollector(constructor_source) + collector = SignatureArgumentsCollector() collector.visit(constructor_ast) assert collector.class_self_id == 'me' - assert len(collector.variables) == 6, 'all the arguments must be detected' - assert_Variable(collector.variables[0], 'an_int', 'int', constructor_source) - assert_Variable(collector.variables[1], 'an_untyped', None, constructor_source) - assert_Variable(collector.variables[2], 'a_compound_type', 'Tuple[float, Dict[str, List[bool]]]', constructor_source) - assert_Variable(collector.variables[3], 'a_default_string', 'str', constructor_source) - assert_Variable(collector.variables[4], 'args', None, constructor_source) - assert_Variable(collector.variables[5], 'kwargs', None, constructor_source) + assert len(collector.arguments) == 7, 'all the arguments must be detected' + assert_Variable(collector.arguments[0], 'me', None, constructor_source) + assert_Variable(collector.arguments[1], 'an_int', 'int', constructor_source) + assert_Variable(collector.arguments[2], 'an_untyped', None, constructor_source) + assert_Variable( + collector.arguments[3], + 'a_compound_type', + 'Tuple[float, Dict[str, List[bool]]]', + constructor_source, + ) + assert_Variable(collector.arguments[4], 'a_default_string', 'str', constructor_source) + assert_Variable(collector.arguments[5], 'args', None, constructor_source) + assert_Variable(collector.arguments[6], 'kwargs', None, constructor_source) + @mark.parametrize( - 'class_self_id,assignment_code,annotation_as_str,self_attributes,variables', [ + 'class_self_id,assignment_code,annotation_as_str,self_attributes,variables', + [ # detects the assignment to a new variable ('self', 'my_var = 5', None, [], [('my_var', None)]), ('self', 'my_var: int = 5', 'int', [], [('my_var', 'int')]), @@ -52,7 +68,13 @@ def test_SignatureVariablesCollector_collect_arguments(): ('self', 'self.my_attr = 6', None, [('my_attr', None)], []), ('self', 'self.my_attr: int = 6', 'int', [('my_attr', 'int')], []), # tuple assignment mixing variable and attribute - ('self', 'my_var, self.my_attr = 5, 6', None, [('my_attr', None)], [('my_var', None)]), + ( + 'self', + 'my_var, self.my_attr = 5, 6', + None, + [('my_attr', None)], + [('my_var', None)], + ), # assignment to a subscript of an attribute ('self', 'self.my_attr[0] = 0', None, [], []), ('self', 'self.my_attr[0]:int = 0', 'int', [], []), @@ -62,10 +84,14 @@ def test_SignatureVariablesCollector_collect_arguments(): # assignment to an attribute of a reference which is not 'self' ('me', 'self.my_attr = 6', None, [], []), ('me', 'self.my_attr: int = 6', 'int', [], []), - ] + ], ) def test_AssignedVariablesCollector_single_assignment_separate_variable_from_instance_attribute( - class_self_id: str, assignment_code: str, annotation_as_str: str, self_attributes: list, variables: list + class_self_id: str, + assignment_code: str, + annotation_as_str: str, + self_attributes: list, + variables: list, ): # the assignment is the first line of the body assignment_ast: AST = parse(assignment_code).body[0] @@ -94,42 +120,58 @@ def test_AssignedVariablesCollector_single_assignment_separate_variable_from_ins for variable, (variable_id, variable_type_str) in zip(assignment_collector.variables, variables): assert_Variable(variable, variable_id, variable_type_str, assignment_code) + @mark.parametrize( - ['class_self_id', 'assignment_code', 'self_attributes_and_variables_by_target'], [ + ['class_self_id', 'assignment_code', 'self_attributes_and_variables_by_target'], + [ ( - 'self', 'x = y = 0', [ + 'self', + 'x = y = 0', + [ ([], ['x']), ([], ['y']), - ] + ], ), ( - 'self', 'self.x = self.y = 0', [ + 'self', + 'self.x = self.y = 0', + [ (['x'], []), (['y'], []), - ] + ], ), ( - 'self', 'self.my_attr = self.my_list[0] = 5', [ + 'self', + 'self.my_attr = self.my_list[0] = 5', + [ (['my_attr'], []), ([], []), - ] + ], ), ( - 'self', 'self.x, self.y = self.origin = (0, 0)', [ + 'self', + 'self.x, self.y = self.origin = (0, 0)', + [ (['x', 'y'], []), (['origin'], []), - ] + ], ), - ] + ], ) def test_AssignedVariablesCollector_multiple_assignments_separate_variable_from_instance_attribute( - class_self_id: str, assignment_code: str, self_attributes_and_variables_by_target: tuple + class_self_id: str, + assignment_code: str, + self_attributes_and_variables_by_target: tuple, ): # the assignment is the first line of the body assignment_ast: AST = parse(assignment_code).body[0] - assert len(assignment_ast.targets) == len(self_attributes_and_variables_by_target), 'test consitency: all targets must be tested' - for assignment_target, (self_attribute_ids, variable_ids) in zip(assignment_ast.targets, self_attributes_and_variables_by_target): + assert len(assignment_ast.targets) == len( + self_attributes_and_variables_by_target + ), 'test consitency: all targets must be tested' + for assignment_target, (self_attribute_ids, variable_ids) in zip( + assignment_ast.targets, self_attributes_and_variables_by_target + ): assignment_collector = AssignedVariablesCollector(class_self_id, None) assignment_collector.visit(assignment_target) @@ -143,48 +185,97 @@ def test_AssignedVariablesCollector_multiple_assignments_separate_variable_from_ assert variable.id == variable_id assert variable.type_expr == None, 'Python does not allow type annotation in multiple assignment' -@mark.parametrize(['full_annotation', 'short_annotation', 'namespaced_definitions', 'module_dict'], [ - ( - # domain.people was imported, people.Person is used - 'people.Person', - 'Person', - ['domain.people.Person'], - { - '__name__': 'testmodule', - 'people': { - 'Person': { - '__module__': 'domain.people', - '__name__': 'Person' - } - } - } - ), - ( - # combination of compound types - 'Dict[id.Identifier,typing.List[domain.Person]]', - 'Dict[Identifier, List[Person]]', - ['typing.Dict', 'id.Identifier', 'typing.List', 'domain.Person'], - { - '__name__': 'testmodule', - 'Dict': Dict, - 'List': List, - 'id': { - 'Identifier': { - '__module__': 'id', - '__name__': 'Identifier', - } + +@mark.parametrize( + ['full_annotation', 'short_annotation', 'namespaced_definitions', 'module_dict'], + [ + ( + # domain.people was imported, people.Person is used + 'people.Person', + 'Person', + ['domain.people.Person'], + { + '__name__': 'testmodule', + 'people': {'Person': {'__module__': 'domain.people', '__name__': 'Person'}}, }, - 'domain': { - 'Person': { - '__module__': 'domain', - '__name__': 'Person', - } - } - } - ) -]) -def test_shorten_compound_type_annotation(full_annotation: str, short_annotation, namespaced_definitions: List[str], module_dict: dict): + ), + ( + # combination of compound types + 'Dict[id.Identifier,typing.List[domain.Person]]', + 'Dict[Identifier, List[Person]]', + ['typing.Dict', 'id.Identifier', 'typing.List', 'domain.Person'], + { + '__name__': 'testmodule', + 'Dict': Dict, + 'List': List, + 'id': { + 'Identifier': { + '__module__': 'id', + '__name__': 'Identifier', + } + }, + 'domain': { + 'Person': { + '__module__': 'domain', + '__name__': 'Person', + } + }, + }, + ), + ], +) +def test_shorten_compound_type_annotation( + full_annotation: str, + short_annotation, + namespaced_definitions: List[str], + module_dict: dict, +): module_resolver = ModuleResolver(MockedInstance(module_dict)) - shortened_annotation, full_namespaced_definitions = shorten_compound_type_annotation(full_annotation, module_resolver) + ( + shortened_annotation, + full_namespaced_definitions, + ) = shorten_compound_type_annotation(full_annotation, module_resolver) assert shortened_annotation == short_annotation assert full_namespaced_definitions == namespaced_definitions + + +class TestTypeVisitor(unittest.TestCase): + def test_return_type_int(self): + source_code = 'def dummy_function() -> int:\n pass' + ast = parse(source_code) + node = ast.body[0].returns + visitor = TypeVisitor() + actual_rtype = visitor.visit(node) + expected_rtype = 'int' + self.assertEqual(expected_rtype, actual_rtype) + + def test_return_type_compound(self): + """Test non-nested compound datatype""" + source_code = 'def dummy_function() -> Tuple[float, str]:\n pass' + ast = parse(source_code) + node = ast.body[0].returns + visitor = TypeVisitor() + actual_rtype = visitor.visit(node) + expected_rtype = 'Tuple[float, str]' + self.assertEqual(expected_rtype, actual_rtype) + + def test_return_type_compound_nested(self): + """Test nested compound datatype""" + source_code = 'def dummy_function() -> Tuple[float, Dict[str, List[bool]]]:\n pass' + ast = parse(source_code) + node = ast.body[0].returns + visitor = TypeVisitor() + actual_rtype = visitor.visit(node) + # assert False + expected_rtype = 'Tuple[float, Dict[str, List[bool]]]' + self.assertEqual(expected_rtype, actual_rtype) + + def test_return_type_user_defined(self): + """Test user-defined class datatype""" + source_code = 'def dummy_function() -> Point:\n pass' + ast = parse(source_code) + node = ast.body[0].returns + visitor = TypeVisitor() + actual_rtype = visitor.visit(node) + expected_rtype = 'Point' + self.assertEqual(expected_rtype, actual_rtype) diff --git a/tests/py2puml/parsing/test_compoundtypesplitter.py b/tests/py2puml/parsing/test_compoundtypesplitter.py index f98fa3a..254ad24 100644 --- a/tests/py2puml/parsing/test_compoundtypesplitter.py +++ b/tests/py2puml/parsing/test_compoundtypesplitter.py @@ -1,53 +1,97 @@ from typing import Tuple -from pytest import raises, mark +from pytest import mark, raises from py2puml.parsing.compoundtypesplitter import CompoundTypeSplitter, remove_forward_references -@mark.parametrize('type_annotation', [ - 'int', - 'str', - '_ast.Name', - 'Tuple[str, withenum.TimeUnit]', - 'List[datetime.date]', - 'modules.withenum.TimeUnit', - 'Dict[str, Dict[str,builtins.float]]', -]) + +@mark.parametrize( + 'type_annotation', + [ + 'int', + 'str', + '_ast.Name', + 'Tuple[str, withenum.TimeUnit]', + 'List[datetime.date]', + 'modules.withenum.TimeUnit', + 'Dict[str, Dict[str,builtins.float]]', + ], +) def test_CompoundTypeSplitter_from_valid_types(type_annotation: str): splitter = CompoundTypeSplitter(type_annotation, 'type.module') assert splitter.compound_type_annotation == type_annotation -@mark.parametrize('type_annotation', [ - None, - '', - '@dataclass', - 'Dict[str: withenum.TimeUnit]', -]) + +@mark.parametrize( + 'type_annotation', + [ + None, + '', + '@dataclass', + 'Dict[str: withenum.TimeUnit]', + ], +) def test_CompoundTypeSplitter_from_invalid_types(type_annotation: str): with raises(ValueError) as ve: splitter = CompoundTypeSplitter(type_annotation, 'type.module') assert str(ve.value) == f'{type_annotation} seems to be an invalid type annotation' -@mark.parametrize('type_annotation,expected_parts', [ - ('int', ('int',)), - ('str', ('str',)), - ('_ast.Name', ('_ast.Name',)), - ('Tuple[str, withenum.TimeUnit]', ('Tuple', '[', 'str', ',', 'withenum.TimeUnit', ']')), - ('List[datetime.date]', ('List', '[', 'datetime.date', ']')), - ('List[IPv6]', ('List', '[', 'IPv6', ']')), - ('modules.withenum.TimeUnit', ('modules.withenum.TimeUnit',)), - ('Dict[str, Dict[str,builtins.float]]', ('Dict', '[', 'str', ',', 'Dict', '[', 'str', ',', 'builtins.float', ']', ']')), - ("typing.List[Package]", ('typing.List', '[', 'Package', ']')), - ("typing.List[ForwardRef('Package')]", ('typing.List', '[', 'py2puml.domain.package.Package', ']')), - ('typing.List[py2puml.domain.umlclass.UmlAttribute]', ('typing.List', '[', 'py2puml.domain.umlclass.UmlAttribute', ']')) -]) + +@mark.parametrize( + 'type_annotation,expected_parts', + [ + ('int', ('int',)), + ('str', ('str',)), + ('_ast.Name', ('_ast.Name',)), + ( + 'Tuple[str, withenum.TimeUnit]', + ('Tuple', '[', 'str', ',', 'withenum.TimeUnit', ']'), + ), + ('List[datetime.date]', ('List', '[', 'datetime.date', ']')), + ('List[IPv6]', ('List', '[', 'IPv6', ']')), + ('modules.withenum.TimeUnit', ('modules.withenum.TimeUnit',)), + ( + 'Dict[str, Dict[str,builtins.float]]', + ( + 'Dict', + '[', + 'str', + ',', + 'Dict', + '[', + 'str', + ',', + 'builtins.float', + ']', + ']', + ), + ), + ('typing.List[Package]', ('typing.List', '[', 'Package', ']')), + ( + "typing.List[ForwardRef('Package')]", + ('typing.List', '[', 'py2puml.domain.package.Package', ']'), + ), + ( + 'typing.List[py2puml.domain.umlclass.UmlAttribute]', + ('typing.List', '[', 'py2puml.domain.umlclass.UmlAttribute', ']'), + ), + ], +) def test_CompoundTypeSplitter_get_parts(type_annotation: str, expected_parts: Tuple[str]): splitter = CompoundTypeSplitter(type_annotation, 'py2puml.domain.package') assert splitter.get_parts() == expected_parts -@mark.parametrize('type_annotation,type_module,without_forward_references', [ - (None, None, None), - ("typing.List[ForwardRef('Package')]", 'py2puml.domain.package', 'typing.List[py2puml.domain.package.Package]'), -]) + +@mark.parametrize( + 'type_annotation,type_module,without_forward_references', + [ + (None, None, None), + ( + "typing.List[ForwardRef('Package')]", + 'py2puml.domain.package', + 'typing.List[py2puml.domain.package.Package]', + ), + ], +) def test_remove_forward_references(type_annotation: str, type_module: str, without_forward_references: str): assert remove_forward_references(type_annotation, type_module) == without_forward_references diff --git a/tests/py2puml/parsing/test_moduleresolver.py b/tests/py2puml/parsing/test_moduleresolver.py index 373df9f..35492e9 100644 --- a/tests/py2puml/parsing/test_moduleresolver.py +++ b/tests/py2puml/parsing/test_moduleresolver.py @@ -7,42 +7,50 @@ def assert_NamespacedType(namespaced_type: NamespacedType, full_namespace_type: assert namespaced_type.full_namespace == full_namespace_type assert namespaced_type.type_name == short_type + def test_ModuleResolver_resolve_full_namespace_type(): - source_module = MockedInstance({ - '__name__': 'tests.modules.withconstructor', - 'modules': { + source_module = MockedInstance( + { + '__name__': 'tests.modules.withconstructor', + 'modules': { + 'withenum': { + 'TimeUnit': { + '__module__': 'tests.modules.withenum', + '__name__': 'TimeUnit', + } + } + }, 'withenum': { 'TimeUnit': { '__module__': 'tests.modules.withenum', - '__name__': 'TimeUnit' + '__name__': 'TimeUnit', } - } - }, - 'withenum': { - 'TimeUnit': { - '__module__': 'tests.modules.withenum', - '__name__': 'TimeUnit' - } - }, - 'Coordinates': { - '__module__': 'tests.modules.withconstructor', - '__name__': 'Coordinates' + }, + 'Coordinates': { + '__module__': 'tests.modules.withconstructor', + '__name__': 'Coordinates', + }, } - }) + ) module_resolver = ModuleResolver(source_module) - assert_NamespacedType(module_resolver.resolve_full_namespace_type( - 'modules.withenum.TimeUnit' - ), 'tests.modules.withenum.TimeUnit', 'TimeUnit') - assert_NamespacedType(module_resolver.resolve_full_namespace_type( - 'withenum.TimeUnit' - ), 'tests.modules.withenum.TimeUnit', 'TimeUnit') - assert_NamespacedType(module_resolver.resolve_full_namespace_type( - 'Coordinates' - ), 'tests.modules.withconstructor.Coordinates', 'Coordinates') + assert_NamespacedType( + module_resolver.resolve_full_namespace_type('modules.withenum.TimeUnit'), + 'tests.modules.withenum.TimeUnit', + 'TimeUnit', + ) + assert_NamespacedType( + module_resolver.resolve_full_namespace_type('withenum.TimeUnit'), + 'tests.modules.withenum.TimeUnit', + 'TimeUnit', + ) + assert_NamespacedType( + module_resolver.resolve_full_namespace_type('Coordinates'), + 'tests.modules.withconstructor.Coordinates', + 'Coordinates', + ) + def test_ModuleResolver_get_module_full_name(): - source_module = MockedInstance({ - '__name__': 'tests.modules.withconstructor' - }) + source_module = MockedInstance({'__name__': 'tests.modules.withconstructor'}) module_resolver = ModuleResolver(source_module) assert module_resolver.get_module_full_name() == 'tests.modules.withconstructor' diff --git a/tests/py2puml/test__init__.py b/tests/py2puml/test__init__.py index 4527cde..b7383a3 100644 --- a/tests/py2puml/test__init__.py +++ b/tests/py2puml/test__init__.py @@ -1,9 +1,11 @@ -from tests import __version__, __description__ +from tests import __description__, __version__ + # Ensures the library version is modified in the pyproject.toml file when upgrading it (pull request) def test_version(): assert __version__ == '0.7.2' + # Description also output in the CLI def test_description(): assert __description__ == 'Generate PlantUML class diagrams to document your Python application.' diff --git a/tests/py2puml/test_cli.py b/tests/py2puml/test_cli.py index b166898..de3df5b 100644 --- a/tests/py2puml/test_cli.py +++ b/tests/py2puml/test_cli.py @@ -1,5 +1,5 @@ from io import StringIO -from subprocess import run, PIPE +from subprocess import PIPE, run from typing import List from pytest import mark @@ -7,21 +7,13 @@ from py2puml.asserts import assert_multilines from py2puml.py2puml import py2puml -from tests import __version__, __description__, TESTS_PATH +from tests import TESTS_PATH, __description__, __version__ -@mark.parametrize( - 'entrypoint', [ - ['py2puml'], - ['python', '-m', 'py2puml'] - ] -) +@mark.parametrize('entrypoint', [['py2puml'], ['python', '-m', 'py2puml']]) def test_cli_consistency_with_the_default_configuration(entrypoint: List[str]): command = entrypoint + ['py2puml/domain', 'py2puml.domain'] - cli_stdout = run(command, - stdout=PIPE, stderr=PIPE, - text=True, check=True - ).stdout + cli_stdout = run(command, stdout=PIPE, stderr=PIPE, text=True, check=True).stdout puml_content = py2puml('py2puml/domain', 'py2puml.domain') @@ -30,54 +22,35 @@ def test_cli_consistency_with_the_default_configuration(entrypoint: List[str]): def test_cli_on_specific_working_directory(): command = ['py2puml', 'withrootnotincwd', 'withrootnotincwd'] - cli_process = run(command, - stdout=PIPE, stderr=PIPE, - text=True, check=True, - cwd='tests/modules' - ) + cli_process = run(command, stdout=PIPE, stderr=PIPE, text=True, check=True, cwd='tests/modules') with open(TESTS_PATH / 'puml_files' / 'withrootnotincwd.puml', 'r', encoding='utf8') as expected_puml_file: assert_multilines( # removes the last return carriage added by the stdout [line for line in StringIO(cli_process.stdout)][:-1], - expected_puml_file + expected_puml_file, ) -@mark.parametrize( - 'version_command', [ - ['-v'], - ['--version'] - ] -) +@mark.parametrize('version_command', [['-v'], ['--version']]) def test_cli_version(version_command: List[str]): - ''' + """ Ensures the consistency between the CLI version and the project version set in pyproject.toml which is not included when the CLI is installed system-wise - ''' + """ command = ['py2puml'] + version_command - cli_version = run(command, - stdout=PIPE, stderr=PIPE, - text=True, check=True - ).stdout + cli_version = run(command, stdout=PIPE, stderr=PIPE, text=True, check=True).stdout assert cli_version == f'py2puml {__version__}\n' -@mark.parametrize( - 'help_command', [ - ['-h'], - ['--help'] - ] -) + +@mark.parametrize('help_command', [['-h'], ['--help']]) def test_cli_help(help_command: List[str]): - ''' + """ Ensures the consistency between the CLI help and the project description set in pyproject.toml which is not included when the CLI is installed system-wise - ''' + """ command = ['py2puml'] + help_command - help_text = run(command, - stdout=PIPE, stderr=PIPE, - text=True, check=True - ).stdout.replace('\n', ' ') + help_text = run(command, stdout=PIPE, stderr=PIPE, text=True, check=True).stdout.replace('\n', ' ') assert __description__ in help_text diff --git a/tests/py2puml/test_py2puml.py b/tests/py2puml/test_py2puml.py index ec2c501..f2f7e85 100644 --- a/tests/py2puml/test_py2puml.py +++ b/tests/py2puml/test_py2puml.py @@ -3,20 +3,30 @@ from py2puml.asserts import assert_py2puml_is_file_content, assert_py2puml_is_stringio +from tests import TESTS_PATH CURRENT_DIR = Path(__file__).parent + def test_py2puml_model_on_py2uml_domain(): - ''' + """ Ensures that the documentation of the py2puml domain model is up-to-date - ''' + """ domain_diagram_file_path = CURRENT_DIR.parent.parent / 'py2puml' / 'py2puml.domain.puml' assert_py2puml_is_file_content('py2puml/domain', 'py2puml.domain', domain_diagram_file_path) + def test_py2puml_with_heavily_nested_model(): - domain_diagram_file_path = CURRENT_DIR.parent / 'modules' / 'withnestednamespace' / 'tests.modules.withnestednamespace.puml' - assert_py2puml_is_file_content('tests/modules/withnestednamespace', 'tests.modules.withnestednamespace', domain_diagram_file_path) + domain_diagram_file_path = ( + CURRENT_DIR.parent / 'modules' / 'withnestednamespace' / 'tests.modules.withnestednamespace.puml' + ) + assert_py2puml_is_file_content( + 'tests/modules/withnestednamespace', + 'tests.modules.withnestednamespace', + domain_diagram_file_path, + ) + def test_py2puml_with_subdomain(): expected = """@startuml tests.modules.withsubdomain @@ -38,5 +48,21 @@ class tests.modules.withsubdomain.withsubdomain.Car { footer Generated by //py2puml// @enduml """ + assert_py2puml_is_stringio( + 'tests/modules/withsubdomain/', + 'tests.modules.withsubdomain', + StringIO(expected), + ) + + +def test_py2puml_with_methods(): + """ + Test py2puml with a class containing methods + """ + with_methods_diagram_file_path = TESTS_PATH / 'puml_files' / 'with_methods.puml' - assert_py2puml_is_stringio('tests/modules/withsubdomain/', 'tests.modules.withsubdomain', StringIO(expected)) + assert_py2puml_is_file_content( + 'tests/modules/withmethods', + 'tests.modules.withmethods', + with_methods_diagram_file_path, + )