diff --git a/README.md b/README.md index a4d13ad..17d82db 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ Python logo - - PlantUML logo + + PlantUML logo

Python to PlantUML

@@ -94,18 +94,8 @@ This outputs the following PlantUML content: ```plantuml @startuml py2puml.domain -namespace py2puml.domain { - namespace package {} - namespace umlclass {} - namespace umlitem {} - namespace umlenum {} - namespace umlrelation {} -} -class py2puml.domain.package.Package { - name: str - children: List[Package] - items_number: int -} +!pragma useIntermediatePackages false + class py2puml.domain.umlclass.UmlAttribute { name: str type: str @@ -135,7 +125,6 @@ class py2puml.domain.umlrelation.UmlRelation { target_fqn: str type: RelType } -py2puml.domain.package.Package *-- py2puml.domain.package.Package py2puml.domain.umlclass.UmlClass *-- py2puml.domain.umlclass.UmlAttribute py2puml.domain.umlitem.UmlItem <|-- py2puml.domain.umlclass.UmlClass py2puml.domain.umlenum.UmlEnum *-- py2puml.domain.umlenum.Member @@ -147,7 +136,7 @@ footer Generated by //py2puml// Using PlantUML, this content is rendered as in this diagram: -![py2puml domain UML Diagram](https://www.plantuml.com/plantuml/png/ZPD1Yzim48Nl-XLpNbWRUZHxs2M4rj1DbZGzbIN8zcmgAikkD2wO9F-zigqWEw1L3i6HPgJlFUdfsH3NrDKIslvBQxz9rTHSAAPuZQRb9TuKuCG0PaLU_k5776S1IicDkLcGk9RaRT4wRPA18Ut6vMyXAuqgW-_2q2_N_kwgWh0s1zNL1UeCXA9n_iAcdnTamQEApnHTUvAVjNmXqgBeAAoB-dOnDiH9b1aKJIETYBj8gvai07xb6kTtfiMRDWTUM38loV62feVpYNWUMWOXkVq6tNxyLMuO8g7g8gIn9Nd5uQw2e7zSTZX7HJUqqjUU3L2FWElvJRZti6wDafDeb5i_shWb-QvaXtBVjpuMg-ths_P7li-tcmmUu3J5uEAg-URRUfVlNpQhTGPFPr-EUlD4ws-tr0XWcawNU5ZS2W1nVKJoi_EWEjspSxYmo8jyU7oCF5eMoxNV8_BCM2INJsUxKOp68WdnOWAfl5j56CBkl4cd9H8pzj4qX1g-eaBD2IieUaXJjp1DsJEgolvZ_m40) +![py2puml domain UML Diagram](https://www.plantuml.com/plantuml/png/ZPBFwzf04CNl-rTChu89z1WyA29jeFx0sbCHcIIZBDtTfBkJWgZ_UyUeuSitO7BQnwxpUSzvcGP6pxKrK9s_Ld96HMbHE_MbydYo27MWr35vIuL6fWtcV_140Ove0YcL6mpXqsmaihcFVbapO_OwgvdWeW0SMaeWi1VDvwhLae9rda1MbaRT-gdpksY8-EA717xemBy_UkuLW0u7pCG5S-xbQoPxnwToTl8U_xf6lfadLzjeIJOZtnd_XwQcGG09i92p8TW6zlfl3_HU07J_GNVUaq7MfOksP7QotuOnNoytwv_fBbsl4XZ1vR7icxoag--BRRgRhUQB12RNzgcRiEiWARTFtRY4ilOv7Tej0J3w4t5xqrR-p2OclGsFnkD17vVgwN9o5L2Vc-hfGyAyxtneYZScQk369Sk0-jMnB9ayV2D77faAoCuGny_1E5PJSeMe_m00) For a full overview of the CLI, run: @@ -213,6 +202,7 @@ poetry run pytest -v --cov=py2puml --cov-branch --cov-report term-missing --cov- # Changelog +* `0.8.1`: delegated the grouping of nested namespaces (see `0.7.0`) to the PlantUML binary, which handles it natively * `0.8.0`: added support for union types, and github actions (pre-commit hooks + automated tests) * `0.7.2`: added the current working directory to the import path to make py2puml work in any directory or in native virtual environment (not handled by poetry) * `0.7.1`: removed obsolete part of documentation: deeply compound types are now well handled (by version `0.7.0`) diff --git a/py2puml/asserts.py b/py2puml/asserts.py index 039d774..7bbce4a 100644 --- a/py2puml/asserts.py +++ b/py2puml/asserts.py @@ -11,16 +11,29 @@ def assert_py2puml_is_file_content(domain_path: str, domain_module: str, diagram assert_py2puml_is_stringio(domain_path, domain_module, expected_puml_file) +def normalize_lines_with_returns(lines_with_returns: Iterable[str]) -> List[str]: + ''' + When comparing contents, each piece of contents can either be: + - a formatted string block output by the py2puml command containg line returns + - a single line of contents read from a file, each line ending with a line return + + This function normalizes each sequence of contents as a list of string lines, + each one finishing without a line return to ease comparison. + ''' + return ''.join(lines_with_returns).split('\n') + + 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)) + puml_content_lines = normalize_lines_with_returns(py2puml(domain_path, domain_module)) + expected_content_lines = normalize_lines_with_returns(expected_content_stream) - assert_multilines(puml_content, expected_content_stream) + assert_multilines(puml_content_lines, expected_content_lines) -def assert_multilines(actual_multilines: List[str], expected_multilines: Iterable[str]): +def assert_multilines(actual_multilines: List[str], expected_multilines: List[str]): line_index = 0 for line_index, (actual_line, expected_line) in enumerate(zip(actual_multilines, expected_multilines)): + # print(f'{actual_line=}\n{expected_line=}') 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 21c2450..776a872 100644 --- a/py2puml/cli.py +++ b/py2puml/cli.py @@ -16,7 +16,7 @@ def run(): argparser = ArgumentParser(description='Generate PlantUML class diagrams to document your Python application.') - argparser.add_argument('-v', '--version', action='version', version='py2puml 0.8.0') + argparser.add_argument('-v', '--version', action='version', version='py2puml 0.8.1') 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) diff --git a/py2puml/domain/package.py b/py2puml/domain/package.py deleted file mode 100644 index 8b5cbd4..0000000 --- a/py2puml/domain/package.py +++ /dev/null @@ -1,10 +0,0 @@ -from dataclasses import dataclass, field -from typing import List - - -@dataclass -class Package: - '''A folder or a python module''' - name: str - children: List['Package'] = field(default_factory=list) - items_number: int = 0 diff --git a/py2puml/export/namespace.py b/py2puml/export/namespace.py deleted file mode 100644 index 3261cd0..0000000 --- a/py2puml/export/namespace.py +++ /dev/null @@ -1,98 +0,0 @@ -from typing import Iterable, List, Tuple - -from py2puml.domain.package import Package -from py2puml.domain.umlitem import UmlItem - -# templating constants -INDENT = ' ' -PUML_NAMESPACE_START_TPL = '{indentation}namespace {namespace_name} {{' -PUML_NAMESPACE_END_TPL = '{indentation}}}\n' - - -def get_or_create_module_package(root_package: Package, domain_parts: List[str]) -> Package: - '''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 - ) - 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 - # - OR it has more than one sub-package (if no item and only 1 subpackage, they can be concatenated) - print_namespace = package_with_items or len(package.children) > 1 - # the indentation for the inner namespace is incremented if the current namespace is printed (because it has uml_items in it) - # otherwise its package name will be used as a prefix for the inner namespaces - next_indentation = indentation_level + 1 if print_namespace else indentation_level - package_with_name = package.name is not None - namespace_names = parent_namespace_names - - # concatenates the package name with the ones of the empty parent parent names - if package_with_name: - namespace_names += (package.name, ) - - # starts the namespace declaration (without an end-of-line line return, we don't know yet whether there is inner content) - start_of_namespace_line = None - if print_namespace: - # 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) - ) - - parent_names = () if print_namespace else namespace_names - has_inner_namespace = False - for sub_package in package.children: - for sub_package_line in visit_package(sub_package, parent_names, next_indentation): - if not has_inner_namespace: - has_inner_namespace = True - # ends the start-of-namespace with a line return because some inner namespace is about to be documented - if print_namespace: - yield f'{start_of_namespace_line}\n' - yield sub_package_line - - # yields the end-of-namespace brace: - # - with an indentation if it had sub-packages - # - right after the opening brace otherwise - if print_namespace: - if has_inner_namespace: - yield PUML_NAMESPACE_END_TPL.format(indentation=INDENT * indentation_level) - 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]) - module_package.items_number += 1 - - 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: - class_package = get_or_create_module_package(root_package, uml_item.fqn.split('.')[:-1]) - class_package.items_number += 1 - - # yields the documentation using a visitor pattern approach - for namespace_line in visit_package(root_package, (), 0): - yield namespace_line diff --git a/py2puml/export/puml.py b/py2puml/export/puml.py index 21e0a85..bedaecd 100644 --- a/py2puml/export/puml.py +++ b/py2puml/export/puml.py @@ -4,15 +4,23 @@ 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 -PUML_FILE_START = '@startuml {diagram_name}\n' -PUML_FILE_FOOTER = 'footer Generated by //py2puml//\n' -PUML_FILE_END = '@enduml\n' -PUML_ITEM_START_TPL = '{item_type} {item_fqn} {{\n' -PUML_ATTR_TPL = ' {attr_name}: {attr_type}{staticity}\n' -PUML_ITEM_END = '}\n' -PUML_RELATION_TPL = '{source_fqn} {rel_type}-- {target_fqn}\n' +PUML_FILE_START = '''@startuml {diagram_name} +!pragma useIntermediatePackages false + +''' +PUML_FILE_FOOTER = '''footer Generated by //py2puml// +''' +PUML_FILE_END = '''@enduml +''' +PUML_ITEM_START_TPL = '''{item_type} {item_fqn} {{ +''' +PUML_ATTR_TPL = ''' {attr_name}: {attr_type}{staticity} +''' +PUML_ITEM_END = '''} +''' +PUML_RELATION_TPL = '''{source_fqn} {rel_type}-- {target_fqn} +''' FEATURE_STATIC = ' {static}' FEATURE_INSTANCE = '' @@ -21,10 +29,6 @@ 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) - # exports the namespaces - for namespace_line in puml_namespace_content(uml_items): - yield namespace_line - # exports the domain classes and enums for uml_item in uml_items: if isinstance(uml_item, UmlEnum): diff --git a/py2puml/py2puml.domain.puml b/py2puml/py2puml.domain.puml index 5eeece8..4f1b120 100644 --- a/py2puml/py2puml.domain.puml +++ b/py2puml/py2puml.domain.puml @@ -1,16 +1,6 @@ @startuml py2puml.domain -namespace py2puml.domain { - namespace package {} - namespace umlclass {} - namespace umlitem {} - namespace umlenum {} - namespace umlrelation {} -} -class py2puml.domain.package.Package { - name: str - children: List[Package] - items_number: int -} +!pragma useIntermediatePackages false + class py2puml.domain.umlclass.UmlAttribute { name: str type: str @@ -40,7 +30,6 @@ class py2puml.domain.umlrelation.UmlRelation { target_fqn: str type: RelType } -py2puml.domain.package.Package *-- py2puml.domain.package.Package py2puml.domain.umlclass.UmlClass *-- py2puml.domain.umlclass.UmlAttribute py2puml.domain.umlitem.UmlItem <|-- py2puml.domain.umlclass.UmlClass py2puml.domain.umlenum.UmlEnum *-- py2puml.domain.umlenum.Member diff --git a/pyproject.toml b/pyproject.toml index cd4c426..bea4578 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "py2puml" -version = "0.8.0" +version = "0.8.1" description = "Generate PlantUML class diagrams to document your Python application." keywords = ["class diagram", "PlantUML", "documentation", "inspection", "AST"] readme = "README.md" diff --git a/tests/modules/withnestednamespace/plantuml_namespace.txt b/tests/modules/withnestednamespace/plantuml_namespace.txt deleted file mode 100644 index 745e723..0000000 --- a/tests/modules/withnestednamespace/plantuml_namespace.txt +++ /dev/null @@ -1,9 +0,0 @@ -namespace tests.modules.withnestednamespace { - namespace nomoduleroot.modulechild.leaf {} - namespace tree {} - namespace branches.branch {} - namespace withonlyonesubpackage.underground { - namespace roots.roots {} - } - namespace trunks.trunk {} -} diff --git a/tests/modules/withnestednamespace/tests.modules.withnestednamespace.puml b/tests/modules/withnestednamespace/tests.modules.withnestednamespace.puml index fd02e9f..01569d1 100644 --- a/tests/modules/withnestednamespace/tests.modules.withnestednamespace.puml +++ b/tests/modules/withnestednamespace/tests.modules.withnestednamespace.puml @@ -1,13 +1,6 @@ @startuml tests.modules.withnestednamespace -namespace tests.modules.withnestednamespace { - namespace nomoduleroot.modulechild.leaf {} - namespace tree {} - namespace branches.branch {} - namespace withonlyonesubpackage.underground { - namespace roots.roots {} - } - namespace trunks.trunk {} -} +!pragma useIntermediatePackages false + class tests.modules.withnestednamespace.nomoduleroot.modulechild.leaf.CommownLeaf { color: int area: float diff --git a/tests/puml_files/withrootnotincwd.puml b/tests/puml_files/withrootnotincwd.puml index 8df89bc..8d97c9b 100644 --- a/tests/puml_files/withrootnotincwd.puml +++ b/tests/puml_files/withrootnotincwd.puml @@ -1,8 +1,6 @@ @startuml withrootnotincwd -namespace withrootnotincwd { - namespace point {} - namespace segment {} -} +!pragma useIntermediatePackages false + class withrootnotincwd.point.Point { x: float y: float diff --git a/tests/py2puml/export/test_namespace.py b/tests/py2puml/export/test_namespace.py deleted file mode 100644 index 6ded9eb..0000000 --- a/tests/py2puml/export/test_namespace.py +++ /dev/null @@ -1,144 +0,0 @@ -from typing import Dict, List, Tuple - -from pytest import mark - -from py2puml.domain.package import Package -from py2puml.domain.umlitem import UmlItem -from py2puml.domain.umlrelation import UmlRelation -from py2puml.export.namespace import build_packages_structure, get_or_create_module_package, visit_package -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'), - ] -) -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) - assert module_package.name == module_parts[-1], 'the module package has the expected name' - - # checks that the hierarchy of intermediary nested packages has been created if necessary - 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 - ) - - 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 - } - ), - ] -) -def test_build_packages_structure(uml_items: List[UmlItem], items_number_by_package_name: Dict[str, int]): - root_package = build_packages_structure(uml_items) - - # ensures that: - # - each uml_item has a package for its module - # - each nested package either has module items or child packages - for uml_item in uml_items: - inner_package = root_package - 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 - ) - - 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}' - - 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' - - -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 { - namespace domain { - namespace package {} - namespace umlclass {} - } - namespace inspection.inspectclass {} -}''' - - -@mark.parametrize( - ['package_to_visit', 'parent_namespace_names', 'indentation_level', 'expected_namespace_lines'], - [ - (Package(None), (), 0, []), # the root package yields no namespace documentation - (Package(None, NO_CHILDREN_PACKAGES, 1), - (), 0, ['namespace {}\n']), # the root package yields namespace documentation if it has uml items - (Package(None, NO_CHILDREN_PACKAGES, 1), (), 1, [' namespace {}\n']), # indentation level of 1 -> 2 spaces - (Package(None, NO_CHILDREN_PACKAGES, 1), - (), 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, (), 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] -): - for expected_namespace_line, namespace_line in zip(expected_namespace_lines, visit_package( - package_to_visit, parent_namespace_names, indentation_level)): - assert expected_namespace_line == namespace_line - - -def test_build_packages_structure_visit_package_from_tree_package( - domain_items_by_fqn: Dict[str, UmlItem], domain_relations: List[UmlRelation] -): - domain_path = 'tests/modules/withnestednamespace' - domain_module = 'tests.modules.withnestednamespace' - inspect_package(domain_path, domain_module, domain_items_by_fqn, domain_relations) - 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, (), 0), - tree_namespace_file)): - assert namespace_line == expected_namespace_line, f'{line_index}: namespace content' diff --git a/tests/py2puml/test__init__.py b/tests/py2puml/test__init__.py index 799886b..045ea99 100644 --- a/tests/py2puml/test__init__.py +++ b/tests/py2puml/test__init__.py @@ -3,7 +3,7 @@ # Ensures the library version is modified in the pyproject.toml file when upgrading it (pull request) def test_version(): - assert __version__ == '0.8.0' + assert __version__ == '0.8.1' # Description also output in the CLI diff --git a/tests/py2puml/test_asserts.py b/tests/py2puml/test_asserts.py new file mode 100644 index 0000000..f15693e --- /dev/null +++ b/tests/py2puml/test_asserts.py @@ -0,0 +1,22 @@ +from io import StringIO +from typing import Iterable, List + +from pytest import mark + +from py2puml.asserts import normalize_lines_with_returns + +PY2PUML_HEADER = '''@startuml py2puml.domain +!pragma useIntermediatePackages false +''' + + +@mark.parametrize( + ['input_lines_with_returns', 'expected_lines'], [ + (['line'], ['line']), + (['line\n'], ['line', '']), + ([PY2PUML_HEADER], ['@startuml py2puml.domain', '!pragma useIntermediatePackages false', '']), + (StringIO(PY2PUML_HEADER), ['@startuml py2puml.domain', '!pragma useIntermediatePackages false', '']), + ] +) +def test_normalize_lines_with_returns(input_lines_with_returns: Iterable[str], expected_lines: List[str]): + assert normalize_lines_with_returns(input_lines_with_returns) == expected_lines diff --git a/tests/py2puml/test_py2puml.py b/tests/py2puml/test_py2puml.py index f4bec28..5621cf0 100644 --- a/tests/py2puml/test_py2puml.py +++ b/tests/py2puml/test_py2puml.py @@ -24,10 +24,8 @@ def test_py2puml_with_heavily_nested_model(): def test_py2puml_with_subdomain(): expected = """@startuml tests.modules.withsubdomain -namespace tests.modules.withsubdomain { - namespace subdomain.insubdomain {} - namespace withsubdomain {} -} +!pragma useIntermediatePackages false + class tests.modules.withsubdomain.subdomain.insubdomain.Engine { horsepower: int }