├── py2puml ├── __init__.py ├── domain │ ├── __init__.py │ ├── umlitem.py │ ├── umlenum.py │ ├── umlrelation.py │ └── umlclass.py ├── parsing │ ├── __init__.py │ ├── parseclassconstructor.py │ ├── compoundtypesplitter.py │ ├── moduleresolver.py │ └── astvisitors.py ├── inspection │ ├── __init__.py │ ├── inspectenum.py │ ├── inspectnamedtuple.py │ ├── inspectpackage.py │ ├── inspectmodule.py │ └── inspectclass.py ├── __main__.py ├── py2puml.py ├── cli.py ├── utils.py ├── py2puml.domain.puml ├── asserts.py └── export │ └── puml.py ├── .python-version ├── tests ├── py2puml │ ├── __init__.py │ ├── test__init__.py │ ├── conftest.py │ ├── test_asserts.py │ ├── parsing │ │ ├── mockedinstance.py │ │ ├── test_moduleresolver.py │ │ ├── test_compoundtypesplitter.py │ │ └── test_astvisitors.py │ ├── inspection │ │ ├── test_inspectenum.py │ │ ├── test_inspectnamedtuple.py │ │ ├── test_inspect_union_types.py │ │ ├── test_inspectdataclass.py │ │ └── test_inspectclass.py │ ├── test_py2puml.py │ └── test_cli.py ├── modules │ ├── withsubdomain │ │ ├── __init__.py │ │ ├── subdomain │ │ │ ├── __init__.py │ │ │ └── insubdomain.py │ │ └── withsubdomain.py │ ├── withconfusingrootpackage │ │ └── test │ │ │ ├── __init__.py │ │ │ └── range.py │ ├── withnestednamespace │ │ ├── nomoduleroot │ │ │ ├── __init__.py │ │ │ └── modulechild │ │ │ │ ├── __init__.py │ │ │ │ └── leaf.py │ │ ├── withonlyonesubpackage │ │ │ ├── __init__.py │ │ │ └── underground │ │ │ │ ├── __init__.py │ │ │ │ └── roots │ │ │ │ └── roots.py │ │ ├── withoutumlitemroot │ │ │ ├── __init__.py │ │ │ └── withoutumlitemleaf │ │ │ │ └── withoutumlitem.py │ │ ├── trunks │ │ │ └── trunk.py │ │ ├── branches │ │ │ └── branch.py │ │ ├── tree.py │ │ └── tests.modules.withnestednamespace.puml │ ├── withenum.py │ ├── withnamedtuple.py │ ├── withinheritedconstructor │ │ ├── metricorigin.py │ │ └── point.py │ ├── withrootnotincwd │ │ ├── point.py │ │ └── segment.py │ ├── withbasictypes.py │ ├── withpkginitandmodule │ │ ├── __init__.py │ │ ├── test.py │ │ └── tests.modules.withpkginitandmodule.puml │ ├── withpkginitonly │ │ ├── __init__.py │ │ └── tests.modules.withpkginitonly.puml │ ├── withabstract.py │ ├── withinheritancewithinmodule.py │ ├── withcomposition.py │ ├── withcompoundtypewithdigits.py │ ├── withuniontypes.py │ ├── withwrappedconstructor.py │ └── withconstructor.py ├── puml_files │ ├── test.puml │ └── withrootnotincwd.puml ├── asserts │ ├── relation.py │ ├── variable.py │ └── attribute.py └── __init__.py ├── poetry.toml ├── commitlint.config.js ├── .gitignore ├── LICENSE ├── .github └── workflows │ └── python-package.yml ├── .pre-commit-config.yaml ├── pyproject.toml ├── CONTRIBUTING.md ├── README.md └── poetry.lock /py2puml/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.10.9 2 | -------------------------------------------------------------------------------- /py2puml/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /py2puml/parsing/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/py2puml/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /py2puml/inspection/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/modules/withsubdomain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/modules/withsubdomain/subdomain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/modules/withconfusingrootpackage/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/modules/withnestednamespace/nomoduleroot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/modules/withnestednamespace/withonlyonesubpackage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/modules/withnestednamespace/withoutumlitemroot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/modules/withnestednamespace/nomoduleroot/modulechild/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /py2puml/__main__.py: -------------------------------------------------------------------------------- 1 | from py2puml.cli import run 2 | 3 | if __name__ == '__main__': 4 | run() 5 | -------------------------------------------------------------------------------- /tests/modules/withnestednamespace/withoutumlitemroot/withoutumlitemleaf/withoutumlitem.py: -------------------------------------------------------------------------------- 1 | THE_RESPONSE = 42 2 | -------------------------------------------------------------------------------- /py2puml/domain/umlitem.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class UmlItem: 6 | name: str 7 | fqn: str 8 | -------------------------------------------------------------------------------- /tests/modules/withenum.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class TimeUnit(Enum): 5 | DAYS = 'd' 6 | HOURS = 'h' 7 | MINUTE = 'm' 8 | -------------------------------------------------------------------------------- /tests/modules/withnamedtuple.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | Circle = namedtuple('Circle', ['x', 'y', 'radius'], defaults=[1]) 4 | -------------------------------------------------------------------------------- /tests/modules/withinheritedconstructor/metricorigin.py: -------------------------------------------------------------------------------- 1 | from .point import Origin 2 | 3 | 4 | class MetricOrigin(Origin): 5 | unit: str = 'm' 6 | -------------------------------------------------------------------------------- /tests/modules/withnestednamespace/trunks/trunk.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class Trunk: 6 | height: float 7 | -------------------------------------------------------------------------------- /tests/modules/withrootnotincwd/point.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class Point: 6 | x: float 7 | y: float 8 | -------------------------------------------------------------------------------- /tests/modules/withnestednamespace/withonlyonesubpackage/underground/__init__.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class Soil: 6 | humidity: float 7 | -------------------------------------------------------------------------------- /tests/modules/withnestednamespace/withonlyonesubpackage/underground/roots/roots.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class Roots: 6 | mass: float 7 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | create = true 3 | in-project = true 4 | prefer-active-python = true 5 | 6 | [repositories] 7 | [repositories.testpypi] 8 | url = "https://test.pypi.org/legacy/" 9 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | '@commitlint/config-angular' 4 | ], 5 | rules: { 6 | 'header-max-length': [2, 'always', 120], 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/modules/withbasictypes.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class Contact: 6 | full_name: str 7 | age: int 8 | weight: float 9 | can_twist_tongue: bool 10 | -------------------------------------------------------------------------------- /tests/modules/withpkginitandmodule/__init__.py: -------------------------------------------------------------------------------- 1 | """This is a package with a __init__.py file only""" 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class InInitTest: 8 | test_member: str 9 | -------------------------------------------------------------------------------- /tests/modules/withpkginitandmodule/test.py: -------------------------------------------------------------------------------- 1 | """This is a package with a __init__.py file only""" 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class InTestModule: 8 | test_member: str 9 | -------------------------------------------------------------------------------- /tests/modules/withpkginitonly/__init__.py: -------------------------------------------------------------------------------- 1 | """This is a package with a __init__.py file only""" 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class InitOnlyTest: 8 | test_member: str 9 | -------------------------------------------------------------------------------- /tests/modules/withrootnotincwd/segment.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from withrootnotincwd.point import Point 4 | 5 | 6 | @dataclass 7 | class Segment: 8 | a: Point 9 | b: Point 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | __pycache__/ 3 | .venv/ 4 | *.egg-info/ 5 | dist/ 6 | .vscode/ 7 | # data generated by pytest-cov 8 | .coverage 9 | # directory where the PlantUML extension produced diagram files 10 | out/ 11 | -------------------------------------------------------------------------------- /tests/puml_files/test.puml: -------------------------------------------------------------------------------- 1 | @startuml test 2 | !pragma useIntermediatePackages false 3 | 4 | class test.range.Range { 5 | start: datetime 6 | stop: datetime 7 | } 8 | footer Generated by //py2puml// 9 | @enduml 10 | -------------------------------------------------------------------------------- /tests/modules/withinheritedconstructor/point.py: -------------------------------------------------------------------------------- 1 | class Point: 2 | def __init__(self, x: float = 0.0, y: float = 0.0): 3 | self.x = x 4 | self.y = y 5 | 6 | 7 | class Origin(Point): 8 | is_origin: bool = True 9 | -------------------------------------------------------------------------------- /tests/modules/withsubdomain/withsubdomain.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from tests.modules.withsubdomain.subdomain.insubdomain import Engine 4 | 5 | 6 | @dataclass 7 | class Car: 8 | name: str 9 | engine: Engine 10 | -------------------------------------------------------------------------------- /tests/modules/withpkginitonly/tests.modules.withpkginitonly.puml: -------------------------------------------------------------------------------- 1 | @startuml tests.modules.withpkginitonly 2 | !pragma useIntermediatePackages false 3 | 4 | class tests.modules.withpkginitonly.InitOnlyTest { 5 | test_member: str 6 | } 7 | footer Generated by //py2puml// 8 | @enduml 9 | -------------------------------------------------------------------------------- /py2puml/domain/umlenum.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | from py2puml.domain.umlitem import UmlItem 5 | 6 | 7 | @dataclass 8 | class Member: 9 | name: str 10 | value: str 11 | 12 | 13 | @dataclass 14 | class UmlEnum(UmlItem): 15 | members: List[Member] 16 | -------------------------------------------------------------------------------- /tests/modules/withabstract.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class ClassTemplate(ABC): 5 | @abstractmethod 6 | def must_be_implemented(self): 7 | """Docstring.""" 8 | 9 | 10 | class ConcreteClass(ClassTemplate): 11 | def must_be_implemented(self): 12 | pass 13 | -------------------------------------------------------------------------------- /py2puml/domain/umlrelation.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum, unique 3 | 4 | 5 | @unique 6 | class RelType(Enum): 7 | COMPOSITION = '*' 8 | INHERITANCE = '<|' 9 | 10 | 11 | @dataclass 12 | class UmlRelation: 13 | source_fqn: str 14 | target_fqn: str 15 | type: RelType 16 | -------------------------------------------------------------------------------- /tests/modules/withnestednamespace/nomoduleroot/modulechild/leaf.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class CommownLeaf: 6 | color: int 7 | area: float 8 | 9 | 10 | @dataclass 11 | class PineLeaf(CommownLeaf): 12 | length: float 13 | 14 | 15 | @dataclass 16 | class OakLeaf(CommownLeaf): 17 | curves: int 18 | -------------------------------------------------------------------------------- /tests/asserts/relation.py: -------------------------------------------------------------------------------- 1 | from py2puml.domain.umlrelation import RelType, UmlRelation 2 | 3 | 4 | def assert_relation(uml_relation: UmlRelation, source_fqn: str, target_fqn: str, rel_type: RelType): 5 | assert uml_relation.source_fqn == source_fqn, 'source end' 6 | assert uml_relation.target_fqn == target_fqn, 'target end' 7 | assert uml_relation.type == rel_type 8 | -------------------------------------------------------------------------------- /tests/modules/withnestednamespace/branches/branch.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | from ..nomoduleroot.modulechild.leaf import OakLeaf 5 | 6 | 7 | @dataclass 8 | class Branch: 9 | length: float 10 | 11 | 12 | @dataclass 13 | class OakBranch(Branch): 14 | sub_branches: List['OakBranch'] 15 | leaves: List[OakLeaf] 16 | -------------------------------------------------------------------------------- /py2puml/domain/umlclass.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | from py2puml.domain.umlitem import UmlItem 5 | 6 | 7 | @dataclass 8 | class UmlAttribute: 9 | name: str 10 | type: str 11 | static: bool 12 | 13 | 14 | @dataclass 15 | class UmlClass(UmlItem): 16 | attributes: List[UmlAttribute] 17 | is_abstract: bool = False 18 | -------------------------------------------------------------------------------- /tests/puml_files/withrootnotincwd.puml: -------------------------------------------------------------------------------- 1 | @startuml withrootnotincwd 2 | !pragma useIntermediatePackages false 3 | 4 | class withrootnotincwd.point.Point { 5 | x: float 6 | y: float 7 | } 8 | class withrootnotincwd.segment.Segment { 9 | a: Point 10 | b: Point 11 | } 12 | withrootnotincwd.segment.Segment *-- withrootnotincwd.point.Point 13 | footer Generated by //py2puml// 14 | @enduml 15 | -------------------------------------------------------------------------------- /tests/modules/withpkginitandmodule/tests.modules.withpkginitandmodule.puml: -------------------------------------------------------------------------------- 1 | @startuml tests.modules.withpkginitandmodule 2 | !pragma useIntermediatePackages false 3 | 4 | class tests.modules.withpkginitandmodule.InInitTest { 5 | test_member: str 6 | } 7 | class tests.modules.withpkginitandmodule.test.InTestModule { 8 | test_member: str 9 | } 10 | footer Generated by //py2puml// 11 | @enduml 12 | -------------------------------------------------------------------------------- /tests/modules/withsubdomain/subdomain/insubdomain.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | # the definition of the horsepower_to_kilowatt function should not break the parsing 5 | def horsepower_to_kilowatt(horsepower: float) -> float: 6 | return horsepower * 745.7 7 | 8 | 9 | @dataclass 10 | class Engine: 11 | horsepower: int 12 | 13 | 14 | @dataclass 15 | class Pilot: 16 | name: str 17 | -------------------------------------------------------------------------------- /tests/modules/withinheritancewithinmodule.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class Animal: 6 | has_notochord: bool 7 | 8 | 9 | @dataclass 10 | class Fish(Animal): 11 | fins_number: int 12 | 13 | 14 | @dataclass 15 | class Light: 16 | luminosity_max: float 17 | 18 | 19 | @dataclass 20 | class GlowingFish(Fish, Light): 21 | glow_for_hunting: bool 22 | glow_for_mating: bool 23 | -------------------------------------------------------------------------------- /tests/asserts/variable.py: -------------------------------------------------------------------------------- 1 | from ast import get_source_segment 2 | 3 | from py2puml.parsing.astvisitors import Variable 4 | 5 | 6 | def assert_Variable(variable: Variable, id: str, type_str: str, source_code: str): 7 | assert variable.id == id 8 | if type_str is None: 9 | assert variable.type_expr is None, 'no type annotation' 10 | else: 11 | assert get_source_segment(source_code, variable.type_expr) == type_str 12 | -------------------------------------------------------------------------------- /tests/py2puml/test__init__.py: -------------------------------------------------------------------------------- 1 | from tests import __description__, __version__ 2 | 3 | 4 | # Ensures the library version is modified in the pyproject.toml file when upgrading it (pull request) 5 | def test_version(): 6 | assert __version__ == '0.10.0' 7 | 8 | 9 | # Description also output in the CLI 10 | def test_description(): 11 | assert __description__ == 'Generate PlantUML class diagrams to document your Python application.' 12 | -------------------------------------------------------------------------------- /tests/asserts/attribute.py: -------------------------------------------------------------------------------- 1 | from py2puml.domain.umlclass import UmlAttribute 2 | 3 | 4 | def assert_attribute(attribute: UmlAttribute, expected_name: str, expected_type: str, expected_staticity: bool): 5 | assert attribute.name == expected_name, f"Got '{attribute.name}' but expected '{expected_name}'" 6 | assert attribute.type == expected_type, f"Got '{attribute.type}' but expected '{expected_type}'" 7 | assert attribute.static == expected_staticity, f"Got '{attribute.static}' but expected '{expected_staticity}'" 8 | -------------------------------------------------------------------------------- /tests/modules/withcomposition.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | 5 | @dataclass 6 | class Address: 7 | street: str 8 | zipcode: str 9 | city: str 10 | 11 | 12 | @dataclass 13 | class Worker: 14 | name: str 15 | # forward refs are accounted for 16 | colleagues: List['Worker'] 17 | boss: 'Worker' 18 | home_address: Address 19 | work_address: Address 20 | 21 | 22 | @dataclass 23 | class Firm: 24 | name: str 25 | employees: List[Worker] 26 | -------------------------------------------------------------------------------- /tests/modules/withnestednamespace/tree.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | from .branches.branch import OakBranch 5 | from .trunks.trunk import Trunk 6 | from .withonlyonesubpackage.underground import Soil 7 | from .withonlyonesubpackage.underground.roots.roots import Roots 8 | 9 | 10 | @dataclass 11 | class Tree: 12 | height: float 13 | roots_depth: float 14 | roots: Roots 15 | soil: Soil 16 | 17 | 18 | @dataclass 19 | class Oak(Tree): 20 | trunk: Trunk 21 | branches: List[OakBranch] 22 | -------------------------------------------------------------------------------- /py2puml/inspection/inspectenum.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Dict, Type 3 | 4 | from py2puml.domain.umlenum import Member, UmlEnum 5 | from py2puml.domain.umlitem import UmlItem 6 | 7 | 8 | def inspect_enum_type(enum_type: Type[Enum], enum_type_fqn: str, domain_items_by_fqn: Dict[str, UmlItem]): 9 | domain_items_by_fqn[enum_type_fqn] = UmlEnum( 10 | name=enum_type.__name__, 11 | fqn=enum_type_fqn, 12 | members=[Member(name=enum_member.name, value=enum_member.value) for enum_member in enum_type], 13 | ) 14 | -------------------------------------------------------------------------------- /tests/modules/withcompoundtypewithdigits.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | 4 | class IPv6: 5 | """class name with digits""" 6 | 7 | def __init__(self, address: str) -> None: 8 | self.address: str = address 9 | 10 | 11 | class Multicast: 12 | def __init__(self, address: IPv6, repetition: int): 13 | # List[IPv6] must be parsed 14 | self.addresses: List[IPv6] = [address] * repetition 15 | 16 | 17 | class Network: 18 | def __init__(self, network_devices: Tuple[IPv6, ...]): 19 | self.devices = network_devices 20 | -------------------------------------------------------------------------------- /py2puml/inspection/inspectnamedtuple.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Type 2 | 3 | from py2puml.domain.umlclass import UmlAttribute, UmlClass 4 | from py2puml.domain.umlitem import UmlItem 5 | 6 | 7 | def inspect_namedtuple_type(namedtuple_type: Type, namedtuple_type_fqn: str, domain_items_by_fqn: Dict[str, UmlItem]): 8 | domain_items_by_fqn[namedtuple_type_fqn] = UmlClass( 9 | name=namedtuple_type.__name__, 10 | fqn=namedtuple_type_fqn, 11 | attributes=[UmlAttribute(tuple_field, 'Any', False) for tuple_field in namedtuple_type._fields], 12 | ) 13 | -------------------------------------------------------------------------------- /py2puml/py2puml.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Iterable, List 2 | 3 | from py2puml.domain.umlitem import UmlItem 4 | from py2puml.domain.umlrelation import UmlRelation 5 | from py2puml.export.puml import to_puml_content 6 | from py2puml.inspection.inspectpackage import inspect_package 7 | 8 | 9 | def py2puml(domain_path: str, domain_module: str) -> Iterable[str]: 10 | domain_items_by_fqn: Dict[str, UmlItem] = {} 11 | domain_relations: List[UmlRelation] = [] 12 | inspect_package(domain_path, domain_module, domain_items_by_fqn, domain_relations) 13 | 14 | return to_puml_content(domain_module, domain_items_by_fqn.values(), domain_relations) 15 | -------------------------------------------------------------------------------- /tests/modules/withconfusingrootpackage/test/range.py: -------------------------------------------------------------------------------- 1 | """ 2 | When attempting to resolve test.range, the python interpreter will search: 3 | - in the current working directory (and should succeed) 4 | - in the test package of the Python standard library (and should fail) 5 | 6 | This non-regression test ensures that the current working directory is 7 | in the first position of the paths of PYTHON_PATH (sys.path) so that 8 | module resolution is attempted first in the inspected codebase. 9 | """ 10 | 11 | from datetime import datetime 12 | 13 | 14 | class Range: 15 | def __init__(self, start: datetime, stop: datetime): 16 | self.start = start 17 | self.stop = stop 18 | -------------------------------------------------------------------------------- /tests/py2puml/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fixtures that can be used by the automated unit tests. 3 | 4 | .. code-block:: python 5 | 6 | def test_using_fixtures(domain_items_by_fqn: Dict[str, UmlItem], domain_relations: List[UmlRelation]): 7 | fqdn = 'tests.modules.withcompoundtypewithdigits' 8 | inspect_module(import_module(fqdn), fqdn, domain_items_by_fqn, domain_relations) 9 | assert len(domain_items_by_fqn) == 2, 'two classes must be inspected' 10 | ... 11 | """ 12 | 13 | from typing import Dict, List 14 | 15 | from pytest import fixture 16 | 17 | from py2puml.domain.umlitem import UmlItem 18 | from py2puml.domain.umlrelation import UmlRelation 19 | 20 | 21 | @fixture(scope='function') 22 | def domain_items_by_fqn() -> Dict[str, UmlItem]: 23 | return {} 24 | 25 | 26 | @fixture(scope='function') 27 | def domain_relations() -> List[UmlRelation]: 28 | return [] 29 | -------------------------------------------------------------------------------- /tests/py2puml/test_asserts.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | from typing import Iterable, List 3 | 4 | from pytest import mark 5 | 6 | from py2puml.asserts import normalize_lines_with_returns 7 | 8 | PY2PUML_HEADER = """@startuml py2puml.domain 9 | !pragma useIntermediatePackages false 10 | """ 11 | 12 | 13 | @mark.parametrize( 14 | ['input_lines_with_returns', 'expected_lines'], 15 | [ 16 | (['line'], ['line']), 17 | (['line\n'], ['line', '']), 18 | ([PY2PUML_HEADER], ['@startuml py2puml.domain', '!pragma useIntermediatePackages false', '']), 19 | (StringIO(PY2PUML_HEADER), ['@startuml py2puml.domain', '!pragma useIntermediatePackages false', '']), 20 | ], 21 | ) 22 | def test_normalize_lines_with_returns(input_lines_with_returns: Iterable[str], expected_lines: List[str]): 23 | assert normalize_lines_with_returns(input_lines_with_returns) == expected_lines 24 | -------------------------------------------------------------------------------- /tests/modules/withuniontypes.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Union 3 | 4 | 5 | @dataclass 6 | class NumberWrapper: 7 | number: Union[int, float] 8 | 9 | 10 | @dataclass 11 | class OptionalNumberWrapper: 12 | number: Union[int, float, None] 13 | 14 | 15 | @dataclass 16 | class NumberWrapperPy3_10: 17 | number: int | float 18 | 19 | 20 | @dataclass 21 | class OptionalNumberWrapperPy3_10: 22 | number: int | float | None 23 | 24 | 25 | class DistanceCalculator: 26 | def __init__( 27 | self, 28 | x_a: Union[int, float], 29 | y_a: Union[int, float, None], 30 | x_b: int | float, 31 | y_b: int | float | None, 32 | euclidian: bool = True, 33 | ): 34 | self.x_a = x_a 35 | self.y_a = y_a 36 | self.x_b = x_b 37 | self.y_b = y_b 38 | space_characs: str | None = 'euclidian space' if euclidian else None 39 | self.space_characs = space_characs 40 | -------------------------------------------------------------------------------- /py2puml/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from argparse import ArgumentParser 5 | from pathlib import Path 6 | from sys import path 7 | 8 | from py2puml.py2puml import py2puml 9 | 10 | 11 | def run(): 12 | # adds the current working directory to the system path in the first place 13 | # to ease module resolution when py2puml imports them 14 | current_working_directory = str(Path.cwd().resolve()) 15 | path.insert(0, current_working_directory) 16 | 17 | argparser = ArgumentParser(description='Generate PlantUML class diagrams to document your Python application.') 18 | 19 | argparser.add_argument('-v', '--version', action='version', version='py2puml 0.10.0') 20 | argparser.add_argument('path', metavar='path', type=str, help='the filepath to the domain') 21 | argparser.add_argument('module', metavar='module', type=str, help='the module name of the domain', default=None) 22 | 23 | args = argparser.parse_args() 24 | print(''.join(py2puml(args.path, args.module))) 25 | -------------------------------------------------------------------------------- /py2puml/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | 4 | def investigate_domain_definition(type_to_inspect: Type): 5 | """ 6 | Utilitary function which inspects the annotations of the given type 7 | """ 8 | type_annotations = getattr(type_to_inspect, '__annotations__', None) 9 | if type_annotations is None: 10 | # print(f'class {type_to_inspect.__module__}.{type_to_inspect.__name__} of type {type(type_to_inspect)} has no annotation') 11 | for attr_class_key in dir(type_to_inspect): 12 | if attr_class_key != '__doc__': 13 | print(f'{type_to_inspect.__name__}.{attr_class_key}:', getattr(type_to_inspect, attr_class_key)) 14 | else: 15 | # print(type_to_inspect.__annotations__) 16 | for attr_name, attr_class in type_annotations.items(): 17 | for attr_class_key in dir(attr_class): 18 | if attr_class_key != '__doc__': 19 | print( 20 | f'{type_to_inspect.__name__}.{attr_name}:', attr_class_key, getattr(attr_class, attr_class_key) 21 | ) 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2021 Luc Sorel-Giffo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: [push] 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.ref }} 7 | cancel-in-progress: true 8 | 9 | env: 10 | MIN_CODE_COVERAGE_PERCENT: 93 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Install Python 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version-file: '.python-version' 23 | 24 | - name: Install poetry 25 | uses: abatilo/actions-poetry@v2 26 | with: 27 | poetry-version: 1.5.1 28 | 29 | - name: Define a cache for the virtual environment based on the dependencies lock file 30 | uses: actions/cache@v3 31 | with: 32 | path: ./.venv 33 | key: venv-${{ hashFiles('poetry.lock') }} 34 | 35 | - name: Install project dependencies 36 | run: poetry install --without lint 37 | 38 | - name: Run automated tests (with code coverage) 39 | run: poetry run pytest -v --cov=py2puml --cov-branch --cov-report term-missing --cov-fail-under $MIN_CODE_COVERAGE_PERCENT 40 | -------------------------------------------------------------------------------- /py2puml/inspection/inspectpackage.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | from pkgutil import walk_packages 3 | from types import ModuleType 4 | from typing import Dict, List 5 | 6 | from py2puml.domain.umlitem import UmlItem 7 | from py2puml.domain.umlrelation import UmlRelation 8 | from py2puml.inspection.inspectmodule import inspect_module 9 | 10 | 11 | def inspect_package( 12 | domain_path: str, domain_module: str, domain_items_by_fqn: Dict[str, UmlItem], domain_relations: List[UmlRelation] 13 | ): 14 | # inspects the package module first, then its children modules and subpackages 15 | item_module = import_module(domain_module) 16 | inspect_module(item_module, domain_module, domain_items_by_fqn, domain_relations) 17 | 18 | for _, name, is_pkg in walk_packages([domain_path], f'{domain_module}.'): 19 | if not is_pkg: 20 | domain_item_module: ModuleType = import_module(name) 21 | inspect_module(domain_item_module, domain_module, domain_items_by_fqn, domain_relations) 22 | item_module = import_module(f'{domain_module}', f'{domain_module}.') 23 | inspect_module(item_module, domain_module, domain_items_by_fqn, domain_relations) 24 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from itertools import takewhile 2 | from pathlib import Path 3 | from re import compile as re_compile 4 | 5 | # exports the version and the project description read from the pyproject.toml file 6 | __version__ = None 7 | __description__ = None 8 | 9 | TESTS_PATH = Path(__file__).parent 10 | PROJECT_PATH = TESTS_PATH.parent 11 | 12 | VERSION_PATTERN = re_compile('^version = "([^"]+)"$') 13 | DESCRIPTION_PATTERN = re_compile('^description = "([^"]+)"$') 14 | 15 | 16 | def get_from_line_and_pattern(content_line: str, pattern) -> str: 17 | pattern_match = pattern.search(content_line) 18 | if pattern_match is None: 19 | return None 20 | else: 21 | return pattern_match.group(1) 22 | 23 | 24 | with open(PROJECT_PATH / 'pyproject.toml', encoding='utf8') as pyproject_file: 25 | for line in takewhile(lambda _: __version__ is None or __description__ is None, pyproject_file): 26 | __version__ = __version__ if __version__ is not None else get_from_line_and_pattern(line, VERSION_PATTERN) 27 | __description__ = ( 28 | __description__ if __description__ is not None else get_from_line_and_pattern(line, DESCRIPTION_PATTERN) 29 | ) 30 | -------------------------------------------------------------------------------- /py2puml/py2puml.domain.puml: -------------------------------------------------------------------------------- 1 | @startuml py2puml.domain 2 | !pragma useIntermediatePackages false 3 | 4 | class py2puml.domain.umlclass.UmlAttribute { 5 | name: str 6 | type: str 7 | static: bool 8 | } 9 | class py2puml.domain.umlclass.UmlClass { 10 | attributes: List[UmlAttribute] 11 | is_abstract: bool 12 | } 13 | class py2puml.domain.umlitem.UmlItem { 14 | name: str 15 | fqn: str 16 | } 17 | class py2puml.domain.umlenum.Member { 18 | name: str 19 | value: str 20 | } 21 | class py2puml.domain.umlenum.UmlEnum { 22 | members: List[Member] 23 | } 24 | enum py2puml.domain.umlrelation.RelType { 25 | COMPOSITION: * {static} 26 | INHERITANCE: <| {static} 27 | } 28 | class py2puml.domain.umlrelation.UmlRelation { 29 | source_fqn: str 30 | target_fqn: str 31 | type: RelType 32 | } 33 | py2puml.domain.umlclass.UmlClass *-- py2puml.domain.umlclass.UmlAttribute 34 | py2puml.domain.umlitem.UmlItem <|-- py2puml.domain.umlclass.UmlClass 35 | py2puml.domain.umlenum.UmlEnum *-- py2puml.domain.umlenum.Member 36 | py2puml.domain.umlitem.UmlItem <|-- py2puml.domain.umlenum.UmlEnum 37 | py2puml.domain.umlrelation.UmlRelation *-- py2puml.domain.umlrelation.RelType 38 | footer Generated by //py2puml// 39 | @enduml 40 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: check-yaml 6 | - id: check-toml 7 | # trims all whitespace from the end of each line 8 | - id: trailing-whitespace 9 | # ensures that all files end in a newline and only a newline 10 | - id: end-of-file-fixer 11 | # prevents large files from being committed (>100kb) 12 | - id: check-added-large-files 13 | args: [--maxkb=100] 14 | # enforce the naming conventions of test scripts 15 | - id: name-tests-test 16 | # tests match test_.*\.py 17 | args: [--pytest-test-first] 18 | exclude: tests.asserts.*|tests.modules.*|tests/py2puml/parsing/mockedinstance.py 19 | 20 | - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook 21 | rev: v9.23.0 22 | hooks: 23 | - id: commitlint 24 | stages: [commit-msg] 25 | additional_dependencies: ['@commitlint/config-angular'] 26 | 27 | - repo: https://github.com/PyCQA/isort 28 | rev: 7.0.0 29 | hooks: 30 | - id: isort 31 | additional_dependencies: [toml] 32 | 33 | - repo: https://github.com/astral-sh/ruff-pre-commit 34 | rev: v0.14.8 35 | hooks: 36 | - id: ruff 37 | - id: ruff-format 38 | -------------------------------------------------------------------------------- /tests/py2puml/parsing/mockedinstance.py: -------------------------------------------------------------------------------- 1 | from json import JSONEncoder, dumps 2 | from typing import Any, Hashable, Union, _GenericAlias, _SpecialForm, _SpecialGenericAlias 3 | 4 | 5 | class MockedInstance: 6 | """ 7 | Creates an object instance from a dictionary 8 | so that access paths like dict['key1']['key2']['key3'] can be replaced by instance.key1.key2.key3 9 | """ 10 | 11 | def __init__(self, inner_attributes_as_dict: dict): 12 | self.update_instance_dict(self, inner_attributes_as_dict) 13 | 14 | def update_instance_dict(self, instance: 'MockedInstance', attributes_dict: dict): 15 | instance.__dict__.update(attributes_dict) 16 | for instance_attribute, value in attributes_dict.items(): 17 | if isinstance(value, dict): 18 | setattr(instance, instance_attribute, MockedInstance(value)) 19 | 20 | def get(self, key: Hashable, default: Any = None) -> Any: 21 | return self.__dict__.get(key, default) 22 | 23 | def __repr__(self): 24 | return dumps(self.__dict__, cls=MockedInstanceEncoder) 25 | 26 | 27 | class MockedInstanceEncoder(JSONEncoder): 28 | def default(self, obj): 29 | if isinstance(obj, MockedInstance): 30 | return obj.__dict__ 31 | elif isinstance(obj, Union[_GenericAlias, _SpecialForm, _SpecialGenericAlias]): 32 | return obj._name 33 | return JSONEncoder.default(self, obj) 34 | -------------------------------------------------------------------------------- /tests/py2puml/inspection/test_inspectenum.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | 3 | from py2puml.domain.umlenum import Member, UmlEnum 4 | from py2puml.domain.umlitem import UmlItem 5 | from py2puml.domain.umlrelation import UmlRelation 6 | from py2puml.inspection.inspectmodule import inspect_domain_definition 7 | 8 | from tests.modules.withenum import TimeUnit 9 | 10 | 11 | def assert_member(member: Member, expected_name: str, expected_value: str): 12 | assert member.name == expected_name 13 | assert member.value == expected_value 14 | 15 | 16 | def test_inspect_enum_type(): 17 | domain_items_by_fqn: Dict[str, UmlItem] = {} 18 | domain_relations: List[UmlRelation] = [] 19 | inspect_domain_definition(TimeUnit, 'tests.modules.withenum', domain_items_by_fqn, domain_relations) 20 | 21 | umlitems_by_fqn = list(domain_items_by_fqn.items()) 22 | assert len(umlitems_by_fqn) == 1, 'one enum must be inspected' 23 | umlenum: UmlEnum 24 | fqn, umlenum = umlitems_by_fqn[0] 25 | assert fqn == 'tests.modules.withenum.TimeUnit' 26 | assert umlenum.fqn == fqn 27 | assert umlenum.name == 'TimeUnit' 28 | members: List[Member] = umlenum.members 29 | assert len(members) == 3, 'enum has 3 members' 30 | assert_member(members[0], 'DAYS', 'd') 31 | assert_member(members[1], 'HOURS', 'h') 32 | assert_member(members[2], 'MINUTE', 'm') 33 | 34 | assert len(domain_relations) == 0, 'inspecting enum must add no relation' 35 | -------------------------------------------------------------------------------- /tests/py2puml/inspection/test_inspectnamedtuple.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | 3 | from py2puml.domain.umlclass import UmlClass 4 | from py2puml.domain.umlitem import UmlItem 5 | from py2puml.domain.umlrelation import UmlRelation 6 | from py2puml.inspection.inspectmodule import inspect_domain_definition 7 | 8 | from tests.asserts.attribute import assert_attribute 9 | from tests.modules.withnamedtuple import Circle 10 | 11 | 12 | def test_parse_namedtupled_class(): 13 | domain_items_by_fqn: Dict[str, UmlItem] = {} 14 | domain_relations: List[UmlRelation] = [] 15 | inspect_domain_definition(Circle, 'tests.modules.withnamedtuple', domain_items_by_fqn, domain_relations) 16 | 17 | umlitems_by_fqn = list(domain_items_by_fqn.items()) 18 | assert len(umlitems_by_fqn) == 1, 'one namedtuple must be inspected' 19 | namedtupled_class: UmlClass 20 | fqn, namedtupled_class = umlitems_by_fqn[0] 21 | assert fqn == 'tests.modules.withnamedtuple.Circle' 22 | assert namedtupled_class.fqn == fqn 23 | assert namedtupled_class.name == 'Circle' 24 | attributes = namedtupled_class.attributes 25 | assert len(attributes) == 3, '3 attributes must be detected in the namedtupled class' 26 | assert_attribute(attributes[0], 'x', 'Any', False) 27 | assert_attribute(attributes[1], 'y', 'Any', False) 28 | assert_attribute(attributes[2], 'radius', 'Any', False) 29 | 30 | assert len(domain_relations) == 0, 'inspecting namedtuple must add no relation' 31 | -------------------------------------------------------------------------------- /tests/modules/withwrappedconstructor.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | 4 | def count_signature_args(func): 5 | @wraps(func) 6 | def signature_args_counter(*args, **kwargs): 7 | print(f'{len(args)} positional arguments, {len(kwargs)} keyword arguments') 8 | 9 | func(*args, **kwargs) 10 | 11 | return signature_args_counter 12 | 13 | 14 | def signature_arg_values(func): 15 | @wraps(func) 16 | def signature_values_lister(*args, **kwargs): 17 | print('positional arguments:', ', '.join(str(arg) for arg in args)) 18 | print('keyword arguments:', ', '.join(f'{key}: {value}' for key, value in kwargs.items())) 19 | func(*args, **kwargs) 20 | 21 | return signature_values_lister 22 | 23 | 24 | class Point: 25 | """ 26 | A Point class, with a constructor which is decorated by wrapping decorators 27 | """ 28 | 29 | @count_signature_args 30 | @signature_arg_values 31 | def __init__(self, x: float, y: float): 32 | self.x = x 33 | self.y = y 34 | 35 | 36 | # Point(2.5, y=3.2) 37 | 38 | 39 | def signature_improper_decorator(func): 40 | def not_wrapping_decorator(*args, **kwargs): 41 | print(f'{len(args) + len(kwargs)} arguments') 42 | func(*args, **kwargs) 43 | 44 | return not_wrapping_decorator 45 | 46 | 47 | class PointDecoratedWithoutWrapping: 48 | """ 49 | A Point class, with a constructor which is decorated by wrapping decorators 50 | """ 51 | 52 | @signature_improper_decorator 53 | def __init__(self, x: float, y: float): 54 | self.x = x 55 | self.y = y 56 | 57 | 58 | # PointDecoratedWithoutWrapping(2.5, y=3.2) 59 | -------------------------------------------------------------------------------- /py2puml/parsing/parseclassconstructor.py: -------------------------------------------------------------------------------- 1 | from ast import AST, parse 2 | from importlib import import_module 3 | from inspect import getsource, unwrap 4 | from textwrap import dedent 5 | from typing import Dict, List, Tuple, Type 6 | 7 | from py2puml.domain.umlclass import UmlAttribute 8 | from py2puml.domain.umlrelation import UmlRelation 9 | from py2puml.parsing.astvisitors import ConstructorVisitor 10 | from py2puml.parsing.moduleresolver import ModuleResolver 11 | 12 | 13 | def parse_class_constructor( 14 | class_type: Type, class_fqn: str, root_module_name: str 15 | ) -> Tuple[List[UmlAttribute], Dict[str, UmlRelation]]: 16 | constructor = getattr(class_type, '__init__', None) 17 | # conditions to meet in order to parse the AST of a constructor 18 | if ( 19 | ( # the constructor must be defined 20 | constructor is None 21 | ) 22 | or ( # the constructor's source code must be available 23 | not hasattr(constructor, '__code__') 24 | ) 25 | or ( # the constructor must belong to the parsed class (not its parent's one) 26 | not constructor.__qualname__.endswith(f'{class_type.__name__}.__init__') 27 | ) 28 | ): 29 | return [], {} 30 | 31 | # gets the original constructor, if wrapped by a decorator 32 | constructor = unwrap(constructor) 33 | 34 | constructor_source: str = dedent(getsource(constructor.__code__)) 35 | constructor_ast: AST = parse(constructor_source) 36 | 37 | module_resolver = ModuleResolver(import_module(class_type.__module__)) 38 | 39 | visitor = ConstructorVisitor(constructor_source, class_type.__name__, root_module_name, module_resolver) 40 | visitor.visit(constructor_ast) 41 | 42 | return visitor.uml_attributes, visitor.uml_relations_by_target_fqn 43 | -------------------------------------------------------------------------------- /py2puml/asserts.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | from pathlib import Path 3 | from typing import Iterable, List, Union 4 | 5 | from py2puml.py2puml import py2puml 6 | 7 | 8 | def assert_py2puml_is_file_content(domain_path: str, domain_module: str, diagram_filepath: Union[str, Path]): 9 | # reads the existing class diagram 10 | with open(diagram_filepath, 'r', encoding='utf8') as expected_puml_file: 11 | assert_py2puml_is_stringio(domain_path, domain_module, expected_puml_file) 12 | 13 | 14 | def normalize_lines_with_returns(lines_with_returns: Iterable[str]) -> List[str]: 15 | """ 16 | When comparing contents, each piece of contents can either be: 17 | - a formatted string block output by the py2puml command containg line returns 18 | - a single line of contents read from a file, each line ending with a line return 19 | 20 | This function normalizes each sequence of contents as a list of string lines, 21 | each one finishing without a line return to ease comparison. 22 | """ 23 | return ''.join(lines_with_returns).split('\n') 24 | 25 | 26 | def assert_py2puml_is_stringio(domain_path: str, domain_module: str, expected_content_stream: StringIO): 27 | puml_content_lines = normalize_lines_with_returns(py2puml(domain_path, domain_module)) 28 | expected_content_lines = normalize_lines_with_returns(expected_content_stream) 29 | 30 | assert_multilines(puml_content_lines, expected_content_lines) 31 | 32 | 33 | def assert_multilines(actual_multilines: List[str], expected_multilines: List[str]): 34 | line_index = 0 35 | for line_index, (actual_line, expected_line) in enumerate(zip(actual_multilines, expected_multilines)): 36 | # print(f'{actual_line=}\n{expected_line=}') 37 | assert actual_line == expected_line, ( 38 | f'actual and expected contents have changed at line {line_index + 1}: {actual_line=}, {expected_line=}' 39 | ) 40 | 41 | assert line_index + 1 == len(actual_multilines), f'actual and expected diagrams have {line_index + 1} lines' 42 | -------------------------------------------------------------------------------- /tests/modules/withconstructor.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from math import pi 3 | from typing import List, Tuple 4 | 5 | from tests import modules 6 | from tests.modules import withenum 7 | from tests.modules.withenum import TimeUnit 8 | 9 | 10 | class Coordinates: 11 | def __init__(self, x: float, y: float): 12 | self.x = x 13 | self.y = y 14 | 15 | 16 | class Point: 17 | PI: float = pi 18 | origin = Coordinates(0, 0) 19 | 20 | @staticmethod 21 | def from_values(x: int, y: str, u: float, z: List[int]) -> 'Point': 22 | return Point(x, y, u, z) 23 | 24 | def get_coordinates(self): 25 | return self.x, self.y 26 | 27 | def __init__(self, x: int, y: str, unit: str, u: float, z: List[int]): 28 | self.coordinates: Coordinates = Coordinates(x, float(y)) 29 | # al the different imports of TimeUnit must be handled and result in the same 'short type' to display 30 | self.day_unit: withenum.TimeUnit = withenum.TimeUnit.DAYS 31 | self.hour_unit: modules.withenum.TimeUnit = modules.withenum.TimeUnit.HOURS 32 | self.time_resolution: Tuple[str, withenum.TimeUnit] = 'minute', TimeUnit.MINUTE 33 | print('instruction to ignore from parsing') 34 | if x is not None and y is not None: 35 | self.x = x 36 | self.y = y 37 | # multiple assignments 38 | self.x_unit = self.y_unit = unit 39 | # type of self.z should be undefined, z has been overridden without type annotation 40 | z, (r, t) = str(x) + y, ('r', 't') 41 | assert r == 'r' 42 | assert t == 't' 43 | self.z = z 44 | # this assignment should be ignored 45 | self.z[2]: int = x 46 | # u param is overridden here (and re-typed), self.w must be float 47 | u: int = 0 48 | self.w = u 49 | # tuple definition of self.u & self.v (type annotations are not possible) 50 | self.u, self.v = (1, y) 51 | # annotated assignment with compound type 52 | self.dates: List[datetime.date] = [] 53 | -------------------------------------------------------------------------------- /tests/py2puml/parsing/test_moduleresolver.py: -------------------------------------------------------------------------------- 1 | from py2puml.parsing.moduleresolver import ModuleResolver, NamespacedType 2 | 3 | from tests.py2puml.parsing.mockedinstance import MockedInstance 4 | 5 | 6 | def assert_NamespacedType(namespaced_type: NamespacedType, full_namespace_type: str, short_type: str): 7 | assert namespaced_type.full_namespace == full_namespace_type 8 | assert namespaced_type.type_name == short_type 9 | 10 | 11 | def test_ModuleResolver_resolve_full_namespace_type(): 12 | source_module = MockedInstance( 13 | { 14 | '__name__': 'tests.modules.withconstructor', 15 | 'modules': {'withenum': {'TimeUnit': {'__module__': 'tests.modules.withenum', '__name__': 'TimeUnit'}}}, 16 | 'withenum': {'TimeUnit': {'__module__': 'tests.modules.withenum', '__name__': 'TimeUnit'}}, 17 | 'Coordinates': {'__module__': 'tests.modules.withconstructor', '__name__': 'Coordinates'}, 18 | } 19 | ) 20 | module_resolver = ModuleResolver(source_module) 21 | assert_NamespacedType( 22 | module_resolver.resolve_full_namespace_type('modules.withenum.TimeUnit'), 23 | 'tests.modules.withenum.TimeUnit', 24 | 'TimeUnit', 25 | ) 26 | assert_NamespacedType( 27 | module_resolver.resolve_full_namespace_type('withenum.TimeUnit'), 'tests.modules.withenum.TimeUnit', 'TimeUnit' 28 | ) 29 | assert_NamespacedType( 30 | module_resolver.resolve_full_namespace_type('Coordinates'), 31 | 'tests.modules.withconstructor.Coordinates', 32 | 'Coordinates', 33 | ) 34 | 35 | 36 | def test_ModuleResolver_get_module_full_name(): 37 | source_module = MockedInstance({'__name__': 'tests.modules.withconstructor'}) 38 | module_resolver = ModuleResolver(source_module) 39 | assert module_resolver.get_module_full_name() == 'tests.modules.withconstructor' 40 | 41 | 42 | def test_ModuleResolver_repr(): 43 | source_module = MockedInstance({'__name__': 'tests.modules.withconstructor'}) 44 | module_resolver = ModuleResolver(source_module) 45 | assert module_resolver.__repr__() == 'ModuleResolver({"__name__": "tests.modules.withconstructor"})' 46 | -------------------------------------------------------------------------------- /py2puml/export/puml.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, List 2 | 3 | from py2puml.domain.umlclass import UmlClass 4 | from py2puml.domain.umlenum import UmlEnum 5 | from py2puml.domain.umlitem import UmlItem 6 | from py2puml.domain.umlrelation import UmlRelation 7 | 8 | PUML_FILE_START = """@startuml {diagram_name} 9 | !pragma useIntermediatePackages false 10 | 11 | """ 12 | PUML_FILE_FOOTER = """footer Generated by //py2puml// 13 | """ 14 | PUML_FILE_END = """@enduml 15 | """ 16 | PUML_ITEM_START_TPL = """{item_type} {item_fqn} {{ 17 | """ 18 | PUML_ATTR_TPL = """ {attr_name}: {attr_type}{staticity} 19 | """ 20 | PUML_ITEM_END = """} 21 | """ 22 | PUML_RELATION_TPL = """{source_fqn} {rel_type}-- {target_fqn} 23 | """ 24 | 25 | FEATURE_STATIC = ' {static}' 26 | FEATURE_INSTANCE = '' 27 | 28 | 29 | def to_puml_content(diagram_name: str, uml_items: List[UmlItem], uml_relations: List[UmlRelation]) -> Iterable[str]: 30 | yield PUML_FILE_START.format(diagram_name=diagram_name) 31 | 32 | # exports the domain classes and enums 33 | for uml_item in uml_items: 34 | if isinstance(uml_item, UmlEnum): 35 | uml_enum: UmlEnum = uml_item 36 | yield PUML_ITEM_START_TPL.format(item_type='enum', item_fqn=uml_enum.fqn) 37 | for member in uml_enum.members: 38 | yield PUML_ATTR_TPL.format(attr_name=member.name, attr_type=member.value, staticity=FEATURE_STATIC) 39 | yield PUML_ITEM_END 40 | elif isinstance(uml_item, UmlClass): 41 | uml_class: UmlClass = uml_item 42 | yield PUML_ITEM_START_TPL.format( 43 | item_type='abstract class' if uml_item.is_abstract else 'class', item_fqn=uml_class.fqn 44 | ) 45 | for uml_attr in uml_class.attributes: 46 | yield PUML_ATTR_TPL.format( 47 | attr_name=uml_attr.name, 48 | attr_type=uml_attr.type, 49 | staticity=FEATURE_STATIC if uml_attr.static else FEATURE_INSTANCE, 50 | ) 51 | yield PUML_ITEM_END 52 | else: 53 | raise TypeError(f'cannot process uml_item of type {uml_item.__class__}') 54 | 55 | # exports the domain relationships between classes and enums 56 | for uml_relation in uml_relations: 57 | yield PUML_RELATION_TPL.format( 58 | source_fqn=uml_relation.source_fqn, rel_type=uml_relation.type.value, target_fqn=uml_relation.target_fqn 59 | ) 60 | 61 | yield PUML_FILE_FOOTER 62 | yield PUML_FILE_END 63 | -------------------------------------------------------------------------------- /tests/modules/withnestednamespace/tests.modules.withnestednamespace.puml: -------------------------------------------------------------------------------- 1 | @startuml tests.modules.withnestednamespace 2 | !pragma useIntermediatePackages false 3 | 4 | class tests.modules.withnestednamespace.nomoduleroot.modulechild.leaf.CommownLeaf { 5 | color: int 6 | area: float 7 | } 8 | class tests.modules.withnestednamespace.nomoduleroot.modulechild.leaf.OakLeaf { 9 | curves: int 10 | } 11 | class tests.modules.withnestednamespace.nomoduleroot.modulechild.leaf.PineLeaf { 12 | length: float 13 | } 14 | class tests.modules.withnestednamespace.tree.Oak { 15 | trunk: Trunk 16 | branches: List[OakBranch] 17 | } 18 | class tests.modules.withnestednamespace.branches.branch.OakBranch { 19 | sub_branches: List[OakBranch] 20 | leaves: List[OakLeaf] 21 | } 22 | class tests.modules.withnestednamespace.withonlyonesubpackage.underground.roots.roots.Roots { 23 | mass: float 24 | } 25 | class tests.modules.withnestednamespace.withonlyonesubpackage.underground.Soil { 26 | humidity: float 27 | } 28 | class tests.modules.withnestednamespace.tree.Tree { 29 | height: float 30 | roots_depth: float 31 | roots: Roots 32 | soil: Soil 33 | } 34 | class tests.modules.withnestednamespace.trunks.trunk.Trunk { 35 | height: float 36 | } 37 | tests.modules.withnestednamespace.nomoduleroot.modulechild.leaf.CommownLeaf <|-- tests.modules.withnestednamespace.nomoduleroot.modulechild.leaf.OakLeaf 38 | tests.modules.withnestednamespace.nomoduleroot.modulechild.leaf.CommownLeaf <|-- tests.modules.withnestednamespace.nomoduleroot.modulechild.leaf.PineLeaf 39 | tests.modules.withnestednamespace.tree.Oak *-- tests.modules.withnestednamespace.trunks.trunk.Trunk 40 | tests.modules.withnestednamespace.tree.Oak *-- tests.modules.withnestednamespace.branches.branch.OakBranch 41 | tests.modules.withnestednamespace.tree.Tree <|-- tests.modules.withnestednamespace.tree.Oak 42 | tests.modules.withnestednamespace.branches.branch.OakBranch *-- tests.modules.withnestednamespace.branches.branch.OakBranch 43 | tests.modules.withnestednamespace.branches.branch.OakBranch *-- tests.modules.withnestednamespace.nomoduleroot.modulechild.leaf.OakLeaf 44 | tests.modules.withnestednamespace.branches.branch.Branch <|-- tests.modules.withnestednamespace.branches.branch.OakBranch 45 | tests.modules.withnestednamespace.tree.Tree *-- tests.modules.withnestednamespace.withonlyonesubpackage.underground.roots.roots.Roots 46 | tests.modules.withnestednamespace.tree.Tree *-- tests.modules.withnestednamespace.withonlyonesubpackage.underground.Soil 47 | footer Generated by //py2puml// 48 | @enduml 49 | -------------------------------------------------------------------------------- /tests/py2puml/test_py2puml.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | from pathlib import Path 3 | 4 | from py2puml.asserts import assert_py2puml_is_file_content, assert_py2puml_is_stringio 5 | 6 | CURRENT_DIR = Path(__file__).parent 7 | 8 | 9 | def test_py2puml_model_on_py2uml_domain(): 10 | """ 11 | Ensures that the documentation of the py2puml domain model is up-to-date 12 | """ 13 | domain_diagram_file_path = CURRENT_DIR.parent.parent / 'py2puml' / 'py2puml.domain.puml' 14 | 15 | assert_py2puml_is_file_content('py2puml/domain', 'py2puml.domain', domain_diagram_file_path) 16 | 17 | 18 | def test_py2puml_with_pkg_init_only(): 19 | """ 20 | Ensure that __init__.py files are also parsed 21 | """ 22 | domain_diagram_file_path = CURRENT_DIR.parent / 'modules/withpkginitonly' / 'tests.modules.withpkginitonly.puml' 23 | 24 | assert_py2puml_is_file_content( 25 | 'tests/modules/withpkginitonly', 'tests.modules.withpkginitonly', domain_diagram_file_path 26 | ) 27 | 28 | 29 | def test_py2puml_with_pkg_init_and_module(): 30 | """ 31 | Ensure that __init__.py files are also parsed, in combination with other module 32 | """ 33 | domain_diagram_file_path = ( 34 | CURRENT_DIR.parent / 'modules/withpkginitandmodule' / 'tests.modules.withpkginitandmodule.puml' 35 | ) 36 | 37 | assert_py2puml_is_file_content( 38 | 'tests/modules/withpkginitandmodule', 'tests.modules.withpkginitandmodule', domain_diagram_file_path 39 | ) 40 | 41 | 42 | def test_py2puml_with_heavily_nested_model(): 43 | domain_diagram_file_path = ( 44 | CURRENT_DIR.parent / 'modules' / 'withnestednamespace' / 'tests.modules.withnestednamespace.puml' 45 | ) 46 | assert_py2puml_is_file_content( 47 | 'tests/modules/withnestednamespace', 'tests.modules.withnestednamespace', domain_diagram_file_path 48 | ) 49 | 50 | 51 | def test_py2puml_with_subdomain(): 52 | expected = """@startuml tests.modules.withsubdomain 53 | !pragma useIntermediatePackages false 54 | 55 | class tests.modules.withsubdomain.subdomain.insubdomain.Engine { 56 | horsepower: int 57 | } 58 | class tests.modules.withsubdomain.subdomain.insubdomain.Pilot { 59 | name: str 60 | } 61 | class tests.modules.withsubdomain.withsubdomain.Car { 62 | name: str 63 | engine: Engine 64 | } 65 | tests.modules.withsubdomain.withsubdomain.Car *-- tests.modules.withsubdomain.subdomain.insubdomain.Engine 66 | footer Generated by //py2puml// 67 | @enduml 68 | """ 69 | 70 | assert_py2puml_is_stringio('tests/modules/withsubdomain/', 'tests.modules.withsubdomain', StringIO(expected)) 71 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "py2puml" 3 | version = "0.10.0" 4 | description = "Generate PlantUML class diagrams to document your Python application." 5 | keywords = ["class diagram", "PlantUML", "documentation", "inspection", "AST"] 6 | readme = "README.md" 7 | repository = "https://github.com/lucsorel/py2puml" 8 | authors = ["Luc Sorel-Giffo"] 9 | maintainers = ["Luc Sorel-Giffo"] 10 | license = "MIT" 11 | include = [ 12 | "CONTRIBUTING.md" 13 | ] 14 | 15 | [tool.poetry.scripts] 16 | py2puml = 'py2puml.cli:run' 17 | 18 | [tool.poetry.dependencies] 19 | python = "^3.8" 20 | 21 | [tool.poetry.group.dev.dependencies] 22 | pytest = "^7.2.1" 23 | pytest-cov = "^4.0.0" 24 | 25 | [tool.poetry.group.lint.dependencies] 26 | pre-commit = "^3.3.3" 27 | 28 | [tool.pytest.ini_options] 29 | console_output_style = "count" 30 | 31 | [tool.isort] 32 | py_version=38 33 | # maintain consistency with other quality tools 34 | line_length = 120 35 | # consistency with ruff-format: mode 3 36 | # from third_party import ( 37 | # lib1, 38 | # lib2, 39 | # [...] 40 | # libn, 41 | # ) 42 | multi_line_output = 3 43 | include_trailing_comma = true 44 | # TESTS->known_tests: create a specific section for imports of tests modules 45 | # (this is the reason why isort is still used alongside ruff-format) 46 | sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER", "TESTS"] 47 | known_tests = ["tests"] 48 | 49 | [tool.ruff] 50 | target-version = "py38" 51 | # maintain consistency with other quality tools 52 | line-length = 120 53 | # activated families of verifications (https://beta.ruff.rs/docs/rules/ 54 | select = ["B", "E", "F", "W", "N", "SIM", "C4"] 55 | # do not check: 56 | # - E501 line lengths: ruff formatter already handles them 57 | # - B905 zip(*, strict=True): strict was introduced in Python 3.10+ 58 | extend-ignore = ["E501", "B905"] 59 | # automatically fixes when possible 60 | fix = true 61 | 62 | [tool.ruff.per-file-ignores] 63 | # do not check unused imports in __init__.py files (they expose module features) 64 | "__init__.py" = ["E402"] 65 | # visiting function names include uppercase words (visit_FunctionDef) 66 | "py2puml/parsing/astvisitors.py" = ["N802"] 67 | "tests/asserts/variable.py" = ["N802"] 68 | "tests/py2puml/parsing/test_astvisitors.py" = ["N802", "N805"] 69 | "tests/py2puml/parsing/test_compoundtypesplitter.py" = ["N802"] 70 | "tests/py2puml/parsing/test_moduleresolver.py" = ["N802"] 71 | "tests/__init__.py" = ["B023"] 72 | # test classes with underscore in their names 73 | "tests/modules/withuniontypes.py" = ['N801'] 74 | 75 | [tool.ruff.format] 76 | indent-style = "space" 77 | line-ending = "lf" 78 | quote-style = "single" 79 | 80 | [tool.ruff.lint.isort] 81 | forced-separate = ["tests"] 82 | 83 | [build-system] 84 | requires = ["poetry-core"] 85 | build-backend = "poetry.core.masonry.api" 86 | -------------------------------------------------------------------------------- /py2puml/inspection/inspectmodule.py: -------------------------------------------------------------------------------- 1 | from dataclasses import is_dataclass 2 | from enum import Enum 3 | from inspect import getmembers, isclass 4 | from types import ModuleType 5 | from typing import Dict, Iterable, List, Type 6 | 7 | from py2puml.domain.umlitem import UmlItem 8 | from py2puml.domain.umlrelation import UmlRelation 9 | from py2puml.inspection.inspectclass import inspect_class_type, inspect_dataclass_type 10 | from py2puml.inspection.inspectenum import inspect_enum_type 11 | from py2puml.inspection.inspectnamedtuple import inspect_namedtuple_type 12 | 13 | 14 | def filter_domain_definitions(module: ModuleType, root_module_name: str) -> Iterable[Type]: 15 | for definition_key in dir(module): 16 | definition_type = getattr(module, definition_key) 17 | if isclass(definition_type): 18 | definition_members = getmembers(definition_type) 19 | definition_module_member = next( 20 | ( 21 | member 22 | for member in definition_members 23 | # ensures that the type belongs to the module being parsed 24 | if member[0] == '__module__' and member[1].startswith(root_module_name) 25 | ), 26 | None, 27 | ) 28 | if definition_module_member is not None: 29 | yield definition_type 30 | 31 | 32 | def inspect_domain_definition( 33 | definition_type: Type, 34 | root_module_name: str, 35 | domain_items_by_fqn: Dict[str, UmlItem], 36 | domain_relations: List[UmlRelation], 37 | ): 38 | definition_type_fqn = f'{definition_type.__module__}.{definition_type.__name__}' 39 | if definition_type_fqn not in domain_items_by_fqn: 40 | if issubclass(definition_type, Enum): 41 | inspect_enum_type(definition_type, definition_type_fqn, domain_items_by_fqn) 42 | elif getattr(definition_type, '_fields', None) is not None: 43 | inspect_namedtuple_type(definition_type, definition_type_fqn, domain_items_by_fqn) 44 | elif is_dataclass(definition_type): 45 | inspect_dataclass_type( 46 | definition_type, definition_type_fqn, root_module_name, domain_items_by_fqn, domain_relations 47 | ) 48 | else: 49 | inspect_class_type( 50 | definition_type, definition_type_fqn, root_module_name, domain_items_by_fqn, domain_relations 51 | ) 52 | 53 | 54 | def inspect_module( 55 | domain_item_module: ModuleType, 56 | root_module_name: str, 57 | domain_items_by_fqn: Dict[str, UmlItem], 58 | domain_relations: List[UmlRelation], 59 | ): 60 | # processes only the definitions declared or imported within the given root module 61 | for definition_type in filter_domain_definitions(domain_item_module, root_module_name): 62 | inspect_domain_definition(definition_type, root_module_name, domain_items_by_fqn, domain_relations) 63 | -------------------------------------------------------------------------------- /tests/py2puml/test_cli.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | from subprocess import PIPE, run 3 | from typing import List 4 | 5 | from pytest import mark 6 | 7 | from py2puml.asserts import assert_multilines 8 | from py2puml.py2puml import py2puml 9 | 10 | from tests import TESTS_PATH, __description__, __version__ 11 | 12 | 13 | @mark.parametrize('entrypoint', [['py2puml'], ['python', '-m', 'py2puml']]) 14 | def test_cli_consistency_with_the_default_configuration(entrypoint: List[str]): 15 | command = entrypoint + ['py2puml/domain', 'py2puml.domain'] 16 | cli_stdout = run(command, stdout=PIPE, stderr=PIPE, text=True, check=True).stdout 17 | 18 | puml_content = py2puml('py2puml/domain', 'py2puml.domain') 19 | 20 | assert ''.join(puml_content).strip() == cli_stdout.strip() 21 | 22 | 23 | @mark.parametrize( 24 | ['command', 'current_working_directory', 'expected_puml_contents_file'], 25 | [ 26 | (['python', '-m', 'py2puml', 'withrootnotincwd', 'withrootnotincwd'], 'tests/modules', 'withrootnotincwd.puml'), 27 | (['py2puml', 'withrootnotincwd', 'withrootnotincwd'], 'tests/modules', 'withrootnotincwd.puml'), 28 | (['python', '-m', 'py2puml', 'test', 'test'], 'tests/modules/withconfusingrootpackage', 'test.puml'), 29 | (['py2puml', 'test', 'test'], 'tests/modules/withconfusingrootpackage', 'test.puml'), 30 | ], 31 | ) 32 | def test_cli_on_specific_working_directory( 33 | command: List[str], current_working_directory: str, expected_puml_contents_file: str 34 | ): 35 | cli_process = run(command, stdout=PIPE, stderr=PIPE, text=True, check=True, cwd=current_working_directory) 36 | 37 | with open(TESTS_PATH / 'puml_files' / expected_puml_contents_file, 'r', encoding='utf8') as expected_puml_file: 38 | assert_multilines( 39 | # removes the last return carriage added by the stdout 40 | list(StringIO(cli_process.stdout))[:-1], 41 | expected_puml_file, 42 | ) 43 | 44 | 45 | @mark.parametrize('version_command', [['-v'], ['--version']]) 46 | def test_cli_version(version_command: List[str]): 47 | """ 48 | Ensures the consistency between the CLI version and the project version set in pyproject.toml 49 | which is not included when the CLI is installed system-wise 50 | """ 51 | command = ['py2puml'] + version_command 52 | cli_version = run(command, stdout=PIPE, stderr=PIPE, text=True, check=True).stdout 53 | 54 | assert cli_version == f'py2puml {__version__}\n' 55 | 56 | 57 | @mark.parametrize('help_command', [['-h'], ['--help']]) 58 | def test_cli_help(help_command: List[str]): 59 | """ 60 | Ensures the consistency between the CLI help and the project description set in pyproject.toml 61 | which is not included when the CLI is installed system-wise 62 | """ 63 | command = ['py2puml'] + help_command 64 | help_text = run(command, stdout=PIPE, stderr=PIPE, text=True, check=True).stdout.replace('\n', ' ') 65 | 66 | assert __description__ in help_text 67 | -------------------------------------------------------------------------------- /tests/py2puml/inspection/test_inspect_union_types.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | from typing import Callable, Dict 3 | 4 | from pytest import fixture 5 | 6 | from py2puml.domain.umlitem import UmlItem 7 | from py2puml.inspection.inspectmodule import inspect_module 8 | 9 | from tests.asserts.attribute import assert_attribute 10 | 11 | 12 | @fixture(scope='module', autouse=True) 13 | def inspected_union_types_items() -> Dict[str, UmlItem]: 14 | """ 15 | Inspects the test module containing several types involving union types, once for all the unit tests involved in this module 16 | """ 17 | fqdn = 'tests.modules.withuniontypes' 18 | domain_items_by_fqn: Dict[str, UmlItem] = {} 19 | inspect_module(import_module(fqdn), fqdn, domain_items_by_fqn, []) 20 | 21 | assert len(domain_items_by_fqn) == 5, 'five union-typed classes must be inspected' 22 | 23 | return domain_items_by_fqn 24 | 25 | 26 | @fixture(scope='function') 27 | def get_union_type_item(inspected_union_types_items: Dict[str, UmlItem]) -> Callable[[str], UmlItem]: 28 | return lambda class_name: inspected_union_types_items[f'tests.modules.withuniontypes.{class_name}'] 29 | 30 | 31 | def test_union_type_number_wrapper(get_union_type_item: Callable[[str], UmlItem]): 32 | umlitem = get_union_type_item('NumberWrapper') 33 | 34 | assert len(umlitem.attributes) == 1, '1 attribute of NumberWrapper must be inspected' 35 | number_attribute = umlitem.attributes[0] 36 | assert_attribute(number_attribute, 'number', 'Union[int, float]', expected_staticity=False) 37 | 38 | 39 | def test_union_type_optional_number_wrapper(get_union_type_item: Callable[[str], UmlItem]): 40 | umlitem = get_union_type_item('OptionalNumberWrapper') 41 | 42 | assert len(umlitem.attributes) == 1, '1 attribute of OptionalNumberWrapper must be inspected' 43 | number_attribute = umlitem.attributes[0] 44 | assert_attribute(number_attribute, 'number', 'Union[int, float, None]', expected_staticity=False) 45 | 46 | 47 | def test_union_type_number_wrapper_py3_10(get_union_type_item: Callable[[str], UmlItem]): 48 | umlitem = get_union_type_item('NumberWrapperPy3_10') 49 | 50 | assert len(umlitem.attributes) == 1, '1 attribute of NumberWrapperPy3_10 must be inspected' 51 | number_attribute = umlitem.attributes[0] 52 | assert_attribute(number_attribute, 'number', 'int | float', expected_staticity=False) 53 | 54 | 55 | def test_union_type_optional_number_wrapper_py3_10(get_union_type_item: Callable[[str], UmlItem]): 56 | umlitem = get_union_type_item('OptionalNumberWrapperPy3_10') 57 | 58 | assert len(umlitem.attributes) == 1, '1 attribute of OptionalNumberWrapperPy3_10 must be inspected' 59 | number_attribute = umlitem.attributes[0] 60 | assert_attribute(number_attribute, 'number', 'int | float | None', expected_staticity=False) 61 | 62 | 63 | def test_union_type_distance_calculator(get_union_type_item: Callable[[str], UmlItem]): 64 | umlitem = get_union_type_item('DistanceCalculator') 65 | 66 | assert len(umlitem.attributes) == 5, '5 attributes of DistanceCalculator must be inspected' 67 | 68 | x_a_attribute = umlitem.attributes[0] 69 | assert_attribute(x_a_attribute, 'x_a', 'Union[int, float]', expected_staticity=False) 70 | 71 | y_a_attribute = umlitem.attributes[1] 72 | assert_attribute(y_a_attribute, 'y_a', 'Union[int, float, None]', expected_staticity=False) 73 | 74 | x_b_attribute = umlitem.attributes[2] 75 | assert_attribute(x_b_attribute, 'x_b', 'int | float', expected_staticity=False) 76 | 77 | y_b_attribute = umlitem.attributes[3] 78 | assert_attribute(y_b_attribute, 'y_b', 'int | float | None', expected_staticity=False) 79 | 80 | space_characs_attribute = umlitem.attributes[4] 81 | assert_attribute(space_characs_attribute, 'space_characs', 'str | None', expected_staticity=False) 82 | -------------------------------------------------------------------------------- /py2puml/parsing/compoundtypesplitter.py: -------------------------------------------------------------------------------- 1 | from re import Pattern 2 | from re import compile as re_compile 3 | from typing import Tuple 4 | 5 | # a class name wrapped by ForwardRef(...) 6 | FORWARD_REFERENCES: Pattern = re_compile(r"ForwardRef\('([^']+)'\)") 7 | 8 | # valid characters for class names and compound types 9 | IS_COMPOUND_TYPE: Pattern = re_compile(r'^[a-z|A-Z|0-9|\[|\]|\.|,|\s|_|\|]+$') 10 | 11 | # characters involved in the build-up of compound types 12 | SPLITTING_CHARACTERS = '[', ']', ',', '|', '...' 13 | 14 | # 'None' in 'Union[str, None]' type signature is changed into 'NoneType' when inspecting a module 15 | LAST_NONETYPE_IN_UNION: Pattern = re_compile(r'Union\[(?:(?:[^\[\]])*NoneType)') 16 | 17 | 18 | def remove_forward_references(compound_type_annotation: str, module_name: str) -> str: 19 | """ 20 | Removes the forward reference mention from the string representation of a type annotation. 21 | This happens when a class attribute refers to the class being defined (where Person.friends is of type List[Person]) 22 | The type which is referred to is prefixed by the module where it is defined to help resolution. 23 | """ 24 | return ( 25 | None 26 | if compound_type_annotation is None 27 | else FORWARD_REFERENCES.sub(f'{module_name}.\\1', compound_type_annotation) 28 | ) 29 | 30 | 31 | def replace_nonetype_occurrences_in_union_types(type_annotation: str) -> str: 32 | """ 33 | `None` types are replaced by `NoneType` during code inspection in type annotations like `Union[str, None]`. 34 | This function replaces back the `NoneType` occurrences to `None` 35 | See https://bugs.python.org/issue44635 36 | """ 37 | if type_annotation is None: 38 | return None 39 | 40 | cleaned_type_annotation = type_annotation 41 | 42 | while (union_match_clauses := list(LAST_NONETYPE_IN_UNION.finditer(cleaned_type_annotation))) and len( 43 | union_match_clauses 44 | ) > 0: 45 | union_last_match_clause = union_match_clauses[-1] 46 | match_start, match_end = union_last_match_clause.span() 47 | cleaned_type_annotation = ( 48 | cleaned_type_annotation[0 : match_end - 8] + 'None' + cleaned_type_annotation[match_end:] 49 | ) 50 | 51 | return cleaned_type_annotation 52 | 53 | 54 | class CompoundTypeSplitter: 55 | """ 56 | Splits the representation of a compound type annotation into a list of: 57 | - its components (that can be resolved against the module where the type annotation was found) 58 | - its structuring characters: '[', ']' and ',' 59 | """ 60 | 61 | def __init__(self, compound_type_annotation: str, module_name: str): 62 | resolved_type_annotations = remove_forward_references(compound_type_annotation, module_name) 63 | resolved_type_annotations = replace_nonetype_occurrences_in_union_types(resolved_type_annotations) 64 | if (resolved_type_annotations is None) or not IS_COMPOUND_TYPE.match(resolved_type_annotations): 65 | raise ValueError(f'{compound_type_annotation} seems to be an invalid type annotation') 66 | 67 | self.compound_type_annotation = resolved_type_annotations 68 | 69 | def get_parts(self) -> Tuple[str]: 70 | "Iteratively splits the type annotation with the different SPLITTING_CHARACTERS" 71 | 72 | parts = [self.compound_type_annotation] 73 | for splitting_character in SPLITTING_CHARACTERS: 74 | new_parts = [] 75 | for part in parts: 76 | splitted_parts = part.split(splitting_character) 77 | new_parts.append(splitted_parts[0]) 78 | # some splitting characters (like ',' and '|' and '[') separate a type annotation into different types 79 | # others (like ']') must just be separated from the text 80 | if len(splitted_parts) > 1: 81 | for splitted_part in splitted_parts[1:]: 82 | new_parts.extend([splitting_character, splitted_part]) 83 | parts = (new_part.strip() for new_part in new_parts if len(new_part.strip()) > 0) 84 | 85 | return tuple(parts) 86 | -------------------------------------------------------------------------------- /py2puml/parsing/moduleresolver.py: -------------------------------------------------------------------------------- 1 | from functools import reduce 2 | from inspect import isclass 3 | from types import ModuleType 4 | from typing import Iterable, List, NamedTuple, Type 5 | 6 | 7 | class NamespacedType(NamedTuple): 8 | """ 9 | Information of a value type: 10 | - the module-prefixed type name 11 | - the short type name 12 | """ 13 | 14 | full_namespace: str 15 | type_name: str 16 | 17 | 18 | EMPTY_NAMESPACED_TYPE = NamespacedType(None, None) 19 | 20 | 21 | def search_in_module_or_builtins(searched_module: ModuleType, namespace: str): 22 | if searched_module is None: 23 | return None 24 | 25 | # searches the namespace in the module definitions and imports 26 | found_module_or_leaf_type = getattr(searched_module, namespace, None) 27 | if found_module_or_leaf_type is not None: 28 | return found_module_or_leaf_type 29 | 30 | # searches the namespace in the builtins otherwise 31 | if hasattr(searched_module, '__builtins__'): 32 | return searched_module.__builtins__.get(namespace, None) 33 | 34 | 35 | def search_in_module(namespaces: List[str], module: ModuleType): 36 | leaf_type: Type = reduce(search_in_module_or_builtins, namespaces, module) 37 | if leaf_type is None: 38 | return EMPTY_NAMESPACED_TYPE 39 | else: 40 | # https://bugs.python.org/issue34422#msg323772 41 | short_type = getattr(leaf_type, '__name__', getattr(leaf_type, '_name', None)) 42 | return NamespacedType(f'{leaf_type.__module__}.{short_type}', short_type) 43 | 44 | 45 | class ModuleResolver: 46 | """ 47 | Given a module and a partially namespaced type name, returns a tuple of information about the type: 48 | - the full-namespaced type name, to draw relationships between types without ambiguity 49 | - the short type name, for display purposes 50 | 51 | Different approaches are combined to derive the type information: 52 | - when the partially namespaced type is found during AST parsing (class constructors) 53 | - when the partially namespaced type is found during class inspection (dataclasses, class static variables, named tuples, enums) 54 | 55 | The two approaches are a bit entangled for now, they could be separated a bit more for performance sake. 56 | """ 57 | 58 | def __init__(self, module: ModuleType): 59 | self.module = module 60 | 61 | def __repr__(self) -> str: 62 | return f'ModuleResolver({self.module})' 63 | 64 | def resolve_full_namespace_type(self, partial_dotted_path: str) -> NamespacedType: 65 | """ 66 | Returns a tuple of 2 strings: 67 | - the full namespaced type 68 | - the short named type 69 | """ 70 | if partial_dotted_path is None: 71 | return EMPTY_NAMESPACED_TYPE 72 | 73 | # special case for Union types 74 | if partial_dotted_path == 'None': 75 | return NamespacedType('builtins.None', 'None') 76 | 77 | def string_repr(module_attribute) -> str: 78 | return ( 79 | f'{module_attribute.__module__}.{module_attribute.__name__}' 80 | if isclass(module_attribute) 81 | else f'{module_attribute}' 82 | ) 83 | 84 | # searches the class in the module imports 85 | namespaced_types_iter: Iterable[NamespacedType] = ( 86 | NamespacedType(string_repr(getattr(self.module, module_var)), module_var) 87 | for module_var in vars(self.module) 88 | ) 89 | found_namespaced_type = next( 90 | ( 91 | namespaced_type 92 | for namespaced_type in namespaced_types_iter 93 | if namespaced_type.full_namespace == partial_dotted_path 94 | ), 95 | None, 96 | ) 97 | 98 | # searches the class in the builtins 99 | if found_namespaced_type is None: 100 | found_namespaced_type = search_in_module(partial_dotted_path.split('.'), self.module) 101 | 102 | return found_namespaced_type 103 | 104 | def get_module_full_name(self) -> str: 105 | return self.module.__name__ 106 | -------------------------------------------------------------------------------- /tests/py2puml/inspection/test_inspectdataclass.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | 3 | from py2puml.domain.umlclass import UmlClass 4 | from py2puml.domain.umlitem import UmlItem 5 | from py2puml.domain.umlrelation import RelType, UmlRelation 6 | from py2puml.inspection.inspectmodule import inspect_domain_definition 7 | 8 | from tests.asserts.attribute import assert_attribute 9 | from tests.asserts.relation import assert_relation 10 | from tests.modules.withbasictypes import Contact 11 | from tests.modules.withcomposition import Worker 12 | from tests.modules.withinheritancewithinmodule import GlowingFish 13 | 14 | 15 | def test_inspect_domain_definition_single_class_without_composition(): 16 | domain_items_by_fqn: Dict[str, UmlItem] = {} 17 | domain_relations: List[UmlRelation] = [] 18 | inspect_domain_definition(Contact, 'tests.modules.withbasictypes', domain_items_by_fqn, domain_relations) 19 | 20 | umlitems_by_fqn = list(domain_items_by_fqn.items()) 21 | assert len(umlitems_by_fqn) == 1, 'one class must be inspected' 22 | 23 | umlclass: UmlClass 24 | fqn, umlclass = umlitems_by_fqn[0] 25 | assert fqn == 'tests.modules.withbasictypes.Contact' 26 | assert umlclass.fqn == fqn 27 | assert umlclass.name == 'Contact' 28 | attributes = umlclass.attributes 29 | assert len(attributes) == 4, 'class has 4 attributes' 30 | assert_attribute(attributes[0], 'full_name', 'str', expected_staticity=False) 31 | assert_attribute(attributes[1], 'age', 'int', expected_staticity=False) 32 | assert_attribute(attributes[2], 'weight', 'float', expected_staticity=False) 33 | assert_attribute(attributes[3], 'can_twist_tongue', 'bool', expected_staticity=False) 34 | 35 | assert len(domain_relations) == 0, 'no component must be detected in this class' 36 | 37 | 38 | def test_inspect_domain_definition_single_class_with_composition(): 39 | domain_items_by_fqn: Dict[str, UmlItem] = {} 40 | domain_relations: List[UmlRelation] = [] 41 | inspect_domain_definition(Worker, 'tests.modules.withcomposition', domain_items_by_fqn, domain_relations) 42 | 43 | assert len(domain_items_by_fqn) == 1, 'one class must be inspected' 44 | assert len(domain_relations) == 2, 'class has 2 domain components' 45 | # forward reference to colleagues 46 | assert_relation( 47 | domain_relations[0], 48 | 'tests.modules.withcomposition.Worker', 49 | 'tests.modules.withcomposition.Worker', 50 | RelType.COMPOSITION, 51 | ) 52 | # adress of worker 53 | assert_relation( 54 | domain_relations[1], 55 | 'tests.modules.withcomposition.Worker', 56 | 'tests.modules.withcomposition.Address', 57 | RelType.COMPOSITION, 58 | ) 59 | 60 | 61 | def test_parse_inheritance_within_module(): 62 | domain_items_by_fqn: Dict[str, UmlItem] = {} 63 | domain_relations: List[UmlRelation] = [] 64 | inspect_domain_definition( 65 | GlowingFish, 'tests.modules.withinheritancewithinmodule', domain_items_by_fqn, domain_relations 66 | ) 67 | 68 | umlitems_by_fqn = list(domain_items_by_fqn.values()) 69 | assert len(umlitems_by_fqn) == 1, 'the class with multiple inheritance was inspected' 70 | child_glowing_fish: UmlClass = umlitems_by_fqn[0] 71 | assert child_glowing_fish.name == 'GlowingFish' 72 | assert child_glowing_fish.fqn == 'tests.modules.withinheritancewithinmodule.GlowingFish' 73 | assert len(child_glowing_fish.attributes) == 2 74 | assert_attribute(child_glowing_fish.attributes[0], 'glow_for_hunting', 'bool', expected_staticity=False) 75 | assert_attribute(child_glowing_fish.attributes[1], 'glow_for_mating', 'bool', expected_staticity=False) 76 | 77 | assert len(domain_relations) == 2, '2 inheritance relations must be inspected' 78 | parent_fish, parent_light = domain_relations 79 | 80 | assert_relation( 81 | parent_fish, 82 | 'tests.modules.withinheritancewithinmodule.Fish', 83 | 'tests.modules.withinheritancewithinmodule.GlowingFish', 84 | RelType.INHERITANCE, 85 | ) 86 | assert_relation( 87 | parent_light, 88 | 'tests.modules.withinheritancewithinmodule.Light', 89 | 'tests.modules.withinheritancewithinmodule.GlowingFish', 90 | RelType.INHERITANCE, 91 | ) 92 | -------------------------------------------------------------------------------- /tests/py2puml/parsing/test_compoundtypesplitter.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | from pytest import mark, raises 4 | 5 | from py2puml.parsing.compoundtypesplitter import ( 6 | CompoundTypeSplitter, 7 | remove_forward_references, 8 | replace_nonetype_occurrences_in_union_types, 9 | ) 10 | 11 | 12 | @mark.parametrize( 13 | 'type_annotation', 14 | [ 15 | 'int', 16 | 'str', 17 | '_ast.Name', 18 | 'Tuple[str, withenum.TimeUnit]', 19 | 'List[datetime.date]', 20 | 'modules.withenum.TimeUnit', 21 | 'Dict[str, Dict[str,builtins.float]]', 22 | ], 23 | ) 24 | def test_CompoundTypeSplitter_from_valid_types(type_annotation: str): 25 | splitter = CompoundTypeSplitter(type_annotation, 'type.module') 26 | assert splitter.compound_type_annotation == type_annotation 27 | 28 | 29 | @mark.parametrize( 30 | 'type_annotation', 31 | [ 32 | None, 33 | '', 34 | '@dataclass', 35 | 'Dict[str: withenum.TimeUnit]', 36 | ], 37 | ) 38 | def test_CompoundTypeSplitter_from_invalid_types(type_annotation: str): 39 | with raises(ValueError) as ve: 40 | CompoundTypeSplitter(type_annotation, 'type.module') 41 | assert str(ve.value) == f'{type_annotation} seems to be an invalid type annotation' 42 | 43 | 44 | @mark.parametrize( 45 | ['type_annotation', 'expected_parts'], 46 | [ 47 | ('int', ('int',)), 48 | ('str', ('str',)), 49 | ('_ast.Name', ('_ast.Name',)), 50 | ('Tuple[str, withenum.TimeUnit]', ('Tuple', '[', 'str', ',', 'withenum.TimeUnit', ']')), 51 | ('List[datetime.date]', ('List', '[', 'datetime.date', ']')), 52 | ('List[IPv6]', ('List', '[', 'IPv6', ']')), 53 | ('modules.withenum.TimeUnit', ('modules.withenum.TimeUnit',)), 54 | ( 55 | 'Dict[str, Dict[str,builtins.float]]', 56 | ('Dict', '[', 'str', ',', 'Dict', '[', 'str', ',', 'builtins.float', ']', ']'), 57 | ), 58 | ('typing.List[Package]', ('typing.List', '[', 'Package', ']')), 59 | ("typing.List[ForwardRef('Package')]", ('typing.List', '[', 'py2puml.domain.package.Package', ']')), 60 | ( 61 | 'typing.List[py2puml.domain.umlclass.UmlAttribute]', 62 | ('typing.List', '[', 'py2puml.domain.umlclass.UmlAttribute', ']'), 63 | ), 64 | ('int|float', ('int', '|', 'float')), 65 | ('int | None', ('int', '|', 'None')), 66 | ], 67 | ) 68 | def test_CompoundTypeSplitter_get_parts(type_annotation: str, expected_parts: Tuple[str]): 69 | splitter = CompoundTypeSplitter(type_annotation, 'py2puml.domain.package') 70 | assert splitter.get_parts() == expected_parts 71 | 72 | 73 | @mark.parametrize( 74 | ['type_annotation', 'type_module', 'without_forward_references'], 75 | [ 76 | (None, None, None), 77 | ("typing.List[ForwardRef('Package')]", 'py2puml.domain.package', 'typing.List[py2puml.domain.package.Package]'), 78 | ], 79 | ) 80 | def test_remove_forward_references(type_annotation: str, type_module: str, without_forward_references: str): 81 | assert remove_forward_references(type_annotation, type_module) == without_forward_references 82 | 83 | 84 | @mark.parametrize( 85 | ['type_annotation', 'expected_cleaned_type_annotation'], 86 | [ 87 | # quick exit for None values 88 | (None, None), 89 | # no changes when Union is not used 90 | ('Dict[str, int]', 'Dict[str, int]'), 91 | # no changes when Union is used without NoneType 92 | ('Union[int, float]', 'Union[int, float]'), 93 | # simple use-case 94 | ('Union[str, NoneType]', 'Union[str, None]'), 95 | # embedded unions with nonetypes 96 | ('Union[int, float, NoneType, Union[str, NoneType]]', 'Union[int, float, None, Union[str, None]]'), 97 | # multiple nonetype occurrences in a single union 98 | ('Union[int, float, NoneType, str, NoneType]', 'Union[int, float, None, str, None]'), 99 | ], 100 | ) 101 | def test_replace_nonetype_occurrences_in_union_types(type_annotation: str, expected_cleaned_type_annotation: str): 102 | assert replace_nonetype_occurrences_in_union_types(type_annotation) == expected_cleaned_type_annotation 103 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | You would like to contribute to `py2puml`, thank you very much! 3 | 4 | Here are a few tips and guidelines to help you in the process. 5 | 6 | # State of mind 7 | 8 | This is an open-source library that I maintain during my free time, which I do not have much: 9 | 10 | * I do what I can to give you some feedback (either a direct response or a message about my unavailability) 11 | * everybody does her best while communicating but misunderstanding happens often when discussing asynchronously with textual message; let's speak friendly and welcome reformulation requests the best we can 12 | * English is the language that we shall use to communicate, let's all be aware that English may not be our native language. 13 | 14 | # Process 15 | 16 | 1. check in the issues (in the closed ones too) if the problem you are facing has already been mentionned, contribute to existing and matching issues before creating a new one 17 | 1. create a new issue if an existing one does not exist already and let's discuss the new feature you request or the bug you are facing. 18 | Details about the way to reproduce a bug helps to fix it 19 | 1. fork the project 20 | 1. implement the feature or fix the bug. 21 | Corresponding unit tests must be added or adapted according to what was discussed in the issue 22 | 1. create a pull-request and notify the library contributors (see the [Contributions](README.md#contributions) section) 23 | 24 | All text files are expected to be encoded in UTF-8. 25 | 26 | ## Contributions and version update 27 | 28 | Add yourself at the bottom of the [contributions section of the README.md](README.md#contributions) file: 29 | 30 | ```text 31 | # Contributions 32 | 33 | * [Luc Sorel-Giffo](https://github.com/lucsorel) 34 | * ... 35 | * [Your Full Name](https://github.com/yourgithubname) 36 | ``` 37 | 38 | Discuss the new version number in the pull request and add a changelog line at the top of the [Changelog section of the README.md](README.md#changelog) file: 39 | 40 | ```text 41 | # Changelog 42 | 43 | * `major.minor.patch`: the feature you added 44 | * ... 45 | ``` 46 | 47 | The version number is carried by the [pyproject.toml](pyproject.toml) file, which is not installed along the production code. 48 | Some manual changes thus need to be made to update it in the CLI; please, update the version number: 49 | 50 | - in the [version attribute of the pyproject.toml](pyproject.toml#L3) file 51 | 52 | ```toml 53 | [tool.poetry] 54 | name = "py2puml" 55 | version = "major.minor.patch" 56 | ``` 57 | 58 | - in the [cli.py module](py2puml/cli.py#L12) (the string value associated to the `version` parameter) 59 | - in the [test__init__.py](tests/py2puml/test__init__.py#L5) file 60 | 61 | 62 | # Code practices 63 | 64 | It takes time to write comprehensive guidelines. 65 | To save time (my writing time and your reading time), I tried to make it short so my best advice is _go have a look at the production and test codes and try to follow the conventions you draw from what you see_ 🙂 66 | 67 | To homogenize code style consistency and enforce code quality, this project uses: 68 | 69 | - the `ruff` linter and formatter 70 | - the `isort` formatter for imports, because it handles the 'tests' sections in the imports 71 | - `pre-commit` hooks that are triggered on the Github CI (in pull-requests) and should be activated locally when contributing to the project: 72 | 73 | ```sh 74 | # installs the dependencies, including pre-commit as a lint dependency 75 | poetry install 76 | 77 | # activates the pre-commit hooks 78 | poetry run pre-commit install --hook-type pre-commit --hook-type commit-msg 79 | 80 | # runs the code quality hooks on the codebase 81 | poetry run pre-commit run --all-files 82 | ``` 83 | 84 | ## Unit tests 85 | 86 | Pull requests must come with unit tests, either new ones (for feature addtitions), changed ones (for feature changes) or non-regression ones (for bug fixes). 87 | Have a look at the [tests](tests/) folder and the existing automated tests scripts to see how they are organized: 88 | 89 | * within the `tests` folder, the subfolders' structure follows the one of the `py2puml` production code 90 | * it helps finding the best location to write the unit tests, and where to find the ones corresponding to a feature you want to understand better 91 | 92 | This project uses the [pytest](https://docs.pytest.org) framework to run the whole tests suit. 93 | It provides useful features like the parametrization of unit test functions and local mocking for example. 94 | 95 | ## Code styling 96 | 97 | Use pythonesque features (list or dict comprehensions, generators) when possible and relevant. 98 | 99 | Use **type annotations** in function signatures and for variables receiving the result of a function call. 100 | 101 | When manipulating **literal strings**: 102 | 103 | ```python 104 | 'favor single quote delimiters' 105 | "use double quote delimiters if the text contains some 'single quotes'" 106 | '''use triple quote delimiters if the text contains both "double" and 'single' quote delimiters''' 107 | 108 | python_version = '3.8+' 109 | f'use f-strings to format strings, py2puml use Python {python_version}' 110 | ``` 111 | -------------------------------------------------------------------------------- /py2puml/inspection/inspectclass.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from importlib import import_module 3 | from inspect import isabstract 4 | from re import compile as re_compile 5 | from typing import Dict, List, Type 6 | 7 | from py2puml.domain.umlclass import UmlAttribute, UmlClass 8 | from py2puml.domain.umlitem import UmlItem 9 | from py2puml.domain.umlrelation import RelType, UmlRelation 10 | from py2puml.parsing.astvisitors import shorten_compound_type_annotation 11 | from py2puml.parsing.moduleresolver import ModuleResolver 12 | from py2puml.parsing.parseclassconstructor import parse_class_constructor 13 | 14 | # from py2puml.utils import investigate_domain_definition 15 | 16 | CONCRETE_TYPE_PATTERN = re_compile("^<(?:class|enum) '([\\.|\\w]+)'>$") 17 | 18 | 19 | def handle_inheritance_relation( 20 | class_type: Type, class_fqn: str, root_module_name: str, domain_relations: List[UmlRelation] 21 | ): 22 | for base_type in getattr(class_type, '__bases__', ()): 23 | base_type_fqn = f'{base_type.__module__}.{base_type.__name__}' 24 | if base_type_fqn.startswith(root_module_name): 25 | domain_relations.append(UmlRelation(base_type_fqn, class_fqn, RelType.INHERITANCE)) 26 | 27 | 28 | def inspect_static_attributes( 29 | class_type: Type, 30 | class_type_fqn: str, 31 | root_module_name: str, 32 | domain_items_by_fqn: Dict[str, UmlItem], 33 | domain_relations: List[UmlRelation], 34 | ) -> List[UmlAttribute]: 35 | """ 36 | Adds the definitions: 37 | - of the inspected type 38 | - of its static attributes from the class annotations (type and relation) 39 | """ 40 | # defines the class being inspected 41 | definition_attrs: List[UmlAttribute] = [] 42 | uml_class = UmlClass( 43 | name=class_type.__name__, fqn=class_type_fqn, attributes=definition_attrs, is_abstract=isabstract(class_type) 44 | ) 45 | domain_items_by_fqn[class_type_fqn] = uml_class 46 | # investigate_domain_definition(class_type) 47 | 48 | type_annotations = getattr(class_type, '__annotations__', None) 49 | if type_annotations is not None: 50 | # stores only once the compositions towards the same class 51 | relations_by_target_fqdn: Dict[str:UmlRelation] = {} 52 | # utility which outputs the fully-qualified name of the attribute types 53 | module_resolver = ModuleResolver(import_module(class_type.__module__)) 54 | 55 | # builds the definitions of the class attrbutes and their relationships by iterating over the type annotations 56 | for attr_name, attr_class in type_annotations.items(): 57 | attr_raw_type = str(attr_class) 58 | concrete_type_match = CONCRETE_TYPE_PATTERN.search(attr_raw_type) 59 | # basic type 60 | if concrete_type_match: 61 | concrete_type = concrete_type_match.group(1) 62 | # appends a composition relationship if the attribute is a class from the inspected domain 63 | if attr_class.__module__.startswith(root_module_name): 64 | attr_type = attr_class.__name__ 65 | attr_fqn = f'{attr_class.__module__}.{attr_class.__name__}' 66 | relations_by_target_fqdn[attr_fqn] = UmlRelation(uml_class.fqn, attr_fqn, RelType.COMPOSITION) 67 | else: 68 | attr_type = concrete_type 69 | # compound type (tuples, lists, dictionaries, etc.) 70 | else: 71 | attr_type, full_namespaced_definitions = shorten_compound_type_annotation( 72 | attr_raw_type, module_resolver 73 | ) 74 | relations_by_target_fqdn.update( 75 | { 76 | attr_fqn: UmlRelation(uml_class.fqn, attr_fqn, RelType.COMPOSITION) 77 | for attr_fqn in full_namespaced_definitions 78 | if attr_fqn.startswith(root_module_name) 79 | } 80 | ) 81 | 82 | uml_attr = UmlAttribute(attr_name, attr_type, static=True) 83 | definition_attrs.append(uml_attr) 84 | 85 | domain_relations.extend(relations_by_target_fqdn.values()) 86 | 87 | return definition_attrs 88 | 89 | 90 | def inspect_class_type( 91 | class_type: Type, 92 | class_type_fqn: str, 93 | root_module_name: str, 94 | domain_items_by_fqn: Dict[str, UmlItem], 95 | domain_relations: List[UmlRelation], 96 | ): 97 | attributes = inspect_static_attributes( 98 | class_type, class_type_fqn, root_module_name, domain_items_by_fqn, domain_relations 99 | ) 100 | instance_attributes, compositions = parse_class_constructor(class_type, class_type_fqn, root_module_name) 101 | attributes.extend(instance_attributes) 102 | domain_relations.extend(compositions.values()) 103 | 104 | handle_inheritance_relation(class_type, class_type_fqn, root_module_name, domain_relations) 105 | 106 | 107 | def inspect_dataclass_type( 108 | class_type: Type[dataclass], 109 | class_type_fqn: str, 110 | root_module_name: str, 111 | domain_items_by_fqn: Dict[str, UmlItem], 112 | domain_relations: List[UmlRelation], 113 | ): 114 | for attribute in inspect_static_attributes( 115 | class_type, class_type_fqn, root_module_name, domain_items_by_fqn, domain_relations 116 | ): 117 | attribute.static = False 118 | 119 | handle_inheritance_relation(class_type, class_type_fqn, root_module_name, domain_relations) 120 | -------------------------------------------------------------------------------- /tests/py2puml/inspection/test_inspectclass.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | from typing import Dict, List 3 | 4 | from py2puml.domain.umlclass import UmlAttribute, UmlClass 5 | from py2puml.domain.umlitem import UmlItem 6 | from py2puml.domain.umlrelation import RelType, UmlRelation 7 | from py2puml.inspection.inspectmodule import inspect_module 8 | 9 | from tests.asserts.attribute import assert_attribute 10 | from tests.asserts.relation import assert_relation 11 | 12 | 13 | def test_inspect_module_should_find_static_and_instance_attributes( 14 | domain_items_by_fqn: Dict[str, UmlItem], domain_relations: List[UmlRelation] 15 | ): 16 | inspect_module( 17 | import_module('tests.modules.withconstructor'), 18 | 'tests.modules.withconstructor', 19 | domain_items_by_fqn, 20 | domain_relations, 21 | ) 22 | 23 | assert len(domain_items_by_fqn) == 2, 'two classes must be inspected' 24 | 25 | # Coordinates UmlClass 26 | coordinates_umlitem: UmlClass = domain_items_by_fqn['tests.modules.withconstructor.Coordinates'] 27 | assert len(coordinates_umlitem.attributes) == 2, '2 attributes of Coordinates must be inspected' 28 | x_attribute, y_attribute = coordinates_umlitem.attributes 29 | assert_attribute(x_attribute, 'x', 'float', expected_staticity=False) 30 | assert_attribute(y_attribute, 'y', 'float', expected_staticity=False) 31 | 32 | # Point UmlClass 33 | point_umlitem: UmlClass = domain_items_by_fqn['tests.modules.withconstructor.Point'] 34 | point_expected_attributes = { 35 | 'PI': ('float', True), 36 | # 'origin': (None, True), # not annotated class variable -> not handled for now 37 | 'coordinates': ('Coordinates', False), 38 | 'day_unit': ('TimeUnit', False), 39 | 'hour_unit': ('TimeUnit', False), 40 | 'time_resolution': ('Tuple[str, TimeUnit]', False), 41 | 'x': ('int', False), 42 | 'y': ('str', False), 43 | 'x_unit': ('str', False), 44 | 'y_unit': ('str', False), 45 | 'z': (None, False), 46 | 'w': ('int', False), 47 | 'u': (None, False), 48 | 'v': (None, False), 49 | 'dates': ('List[date]', False), 50 | } 51 | assert len(point_umlitem.attributes) == len(point_expected_attributes), 'all Point attributes must be verified' 52 | for attribute_name, (atrribute_type, attribute_staticity) in point_expected_attributes.items(): 53 | point_attribute: UmlAttribute = next( 54 | (attribute for attribute in point_umlitem.attributes if attribute.name == attribute_name), None 55 | ) 56 | assert point_attribute is not None, f'attribute {attribute_name} must be detected' 57 | assert_attribute(point_attribute, attribute_name, atrribute_type, expected_staticity=attribute_staticity) 58 | 59 | # Coordinates is a component of Point 60 | assert len(domain_relations) == 1, '1 composition' 61 | assert_relation( 62 | domain_relations[0], 63 | 'tests.modules.withconstructor.Point', 64 | 'tests.modules.withconstructor.Coordinates', 65 | RelType.COMPOSITION, 66 | ) 67 | 68 | 69 | def test_inspect_module_should_find_abstract_class( 70 | domain_items_by_fqn: Dict[str, UmlItem], domain_relations: List[UmlRelation] 71 | ): 72 | inspect_module( 73 | import_module('tests.modules.withabstract'), 'tests.modules.withabstract', domain_items_by_fqn, domain_relations 74 | ) 75 | 76 | assert len(domain_items_by_fqn) == 2, 'two classes must be inspected' 77 | 78 | class_template_umlitem: UmlClass = domain_items_by_fqn['tests.modules.withabstract.ClassTemplate'] 79 | assert class_template_umlitem.is_abstract 80 | 81 | concrete_class_umlitem: UmlClass = domain_items_by_fqn['tests.modules.withabstract.ConcreteClass'] 82 | assert not concrete_class_umlitem.is_abstract 83 | 84 | assert len(domain_relations) == 1, 'one inheritance relationship between the two must be inspected' 85 | assert domain_relations[0].source_fqn == 'tests.modules.withabstract.ClassTemplate' 86 | assert domain_relations[0].target_fqn == 'tests.modules.withabstract.ConcreteClass' 87 | 88 | 89 | def test_inspect_module_parse_class_constructor_should_not_process_inherited_constructor( 90 | domain_items_by_fqn: Dict[str, UmlItem], domain_relations: List[UmlRelation] 91 | ): 92 | # inspects the two sub-modules 93 | inspect_module( 94 | import_module('tests.modules.withinheritedconstructor.point'), 95 | 'tests.modules.withinheritedconstructor.point', 96 | domain_items_by_fqn, 97 | domain_relations, 98 | ) 99 | inspect_module( 100 | import_module('tests.modules.withinheritedconstructor.metricorigin'), 101 | 'tests.modules.withinheritedconstructor.metricorigin', 102 | domain_items_by_fqn, 103 | domain_relations, 104 | ) 105 | 106 | assert len(domain_items_by_fqn) == 3, 'three classes must be inspected' 107 | 108 | # Point UmlClass 109 | point_umlitem: UmlClass = domain_items_by_fqn['tests.modules.withinheritedconstructor.point.Point'] 110 | assert len(point_umlitem.attributes) == 2, '2 attributes of Point must be inspected' 111 | x_attribute, y_attribute = point_umlitem.attributes 112 | assert_attribute(x_attribute, 'x', 'float', expected_staticity=False) 113 | assert_attribute(y_attribute, 'y', 'float', expected_staticity=False) 114 | 115 | # Origin UmlClass 116 | origin_umlitem: UmlClass = domain_items_by_fqn['tests.modules.withinheritedconstructor.point.Origin'] 117 | assert len(origin_umlitem.attributes) == 1, '1 attribute of Origin must be inspected' 118 | is_origin_attribute = origin_umlitem.attributes[0] 119 | assert_attribute(is_origin_attribute, 'is_origin', 'bool', expected_staticity=True) 120 | 121 | # MetricOrigin UmlClass 122 | metric_origin_umlitem: UmlClass = domain_items_by_fqn[ 123 | 'tests.modules.withinheritedconstructor.metricorigin.MetricOrigin' 124 | ] 125 | assert len(metric_origin_umlitem.attributes) == 1, '1 attribute of MetricOrigin must be inspected' 126 | unit_attribute = metric_origin_umlitem.attributes[0] 127 | assert_attribute(unit_attribute, 'unit', 'str', expected_staticity=True) 128 | 129 | 130 | def test_inspect_module_should_unwrap_decorated_constructor( 131 | domain_items_by_fqn: Dict[str, UmlItem], domain_relations: List[UmlRelation] 132 | ): 133 | inspect_module( 134 | import_module('tests.modules.withwrappedconstructor'), 135 | 'tests.modules.withwrappedconstructor', 136 | domain_items_by_fqn, 137 | domain_relations, 138 | ) 139 | 140 | assert len(domain_items_by_fqn) == 2, 'two classes must be inspected' 141 | 142 | # Point UmlClass 143 | point_umlitem: UmlClass = domain_items_by_fqn['tests.modules.withwrappedconstructor.Point'] 144 | assert len(point_umlitem.attributes) == 2, '2 attributes of Point must be inspected' 145 | x_attribute, y_attribute = point_umlitem.attributes 146 | assert_attribute(x_attribute, 'x', 'float', expected_staticity=False) 147 | assert_attribute(y_attribute, 'y', 'float', expected_staticity=False) 148 | 149 | # PointDecoratedWithoutWrapping UmlClass 150 | point_without_wrapping_umlitem: UmlClass = domain_items_by_fqn[ 151 | 'tests.modules.withwrappedconstructor.PointDecoratedWithoutWrapping' 152 | ] 153 | assert len(point_without_wrapping_umlitem.attributes) == 0, ( 154 | 'the attributes of the original constructor could not be found, the constructor was not wrapped by the decorator' 155 | ) 156 | 157 | 158 | def test_inspect_module_should_handle_compound_types_with_numbers_in_their_name( 159 | domain_items_by_fqn: Dict[str, UmlItem], domain_relations: List[UmlRelation] 160 | ): 161 | fqdn = 'tests.modules.withcompoundtypewithdigits' 162 | inspect_module(import_module(fqdn), fqdn, domain_items_by_fqn, domain_relations) 163 | 164 | assert len(domain_items_by_fqn) == 3, 'three classes must be inspected' 165 | 166 | # IPv6 UmlClass 167 | ipv6_umlitem: UmlClass = domain_items_by_fqn[f'{fqdn}.IPv6'] 168 | assert len(ipv6_umlitem.attributes) == 1, '1 attributes of IPv6 must be inspected' 169 | address_attribute = ipv6_umlitem.attributes[0] 170 | assert_attribute(address_attribute, 'address', 'str', expected_staticity=False) 171 | 172 | # Multicast UmlClass 173 | multicast_umlitem: UmlClass = domain_items_by_fqn[f'{fqdn}.Multicast'] 174 | assert len(multicast_umlitem.attributes) == 1, '1 attributes of Multicast must be inspected' 175 | address_attribute = multicast_umlitem.attributes[0] 176 | assert_attribute(address_attribute, 'addresses', 'List[IPv6]', expected_staticity=False) 177 | 178 | # Network UmlClass 179 | network_umlitem: UmlClass = domain_items_by_fqn[f'{fqdn}.Network'] 180 | assert len(network_umlitem.attributes) == 1, '1 attributes of Network must be inspected' 181 | devices_attribute = network_umlitem.attributes[0] 182 | assert_attribute(devices_attribute, 'devices', 'Tuple[IPv6, ...]', expected_staticity=False) 183 | -------------------------------------------------------------------------------- /py2puml/parsing/astvisitors.py: -------------------------------------------------------------------------------- 1 | from ast import ( 2 | AnnAssign, 3 | Assign, 4 | Attribute, 5 | BinOp, 6 | FunctionDef, 7 | Name, 8 | NodeVisitor, 9 | Subscript, 10 | arg, 11 | expr, 12 | get_source_segment, 13 | ) 14 | from collections import namedtuple 15 | from typing import Dict, List, Tuple 16 | 17 | from py2puml.domain.umlclass import UmlAttribute 18 | from py2puml.domain.umlrelation import RelType, UmlRelation 19 | from py2puml.parsing.compoundtypesplitter import SPLITTING_CHARACTERS, CompoundTypeSplitter 20 | from py2puml.parsing.moduleresolver import ModuleResolver 21 | 22 | Variable = namedtuple('Variable', ['id', 'type_expr']) 23 | 24 | 25 | class SignatureVariablesCollector(NodeVisitor): 26 | """ 27 | Collects the variables and their type annotations from the signature of a constructor method 28 | """ 29 | 30 | def __init__(self, constructor_source: str, *args, **kwargs): 31 | super().__init__(*args, **kwargs) 32 | self.constructor_source = constructor_source 33 | self.class_self_id: str = None 34 | self.variables: List[Variable] = [] 35 | 36 | def visit_arg(self, node: arg): 37 | variable = Variable(node.arg, node.annotation) 38 | 39 | # first constructor variable is the name for the 'self' reference 40 | if self.class_self_id is None: 41 | self.class_self_id = variable.id 42 | # other arguments are constructor parameters 43 | else: 44 | self.variables.append(variable) 45 | 46 | 47 | class AssignedVariablesCollector(NodeVisitor): 48 | """Parses the target of an assignment statement to detect whether the value is assigned to a variable or an instance attribute""" 49 | 50 | def __init__(self, class_self_id: str, annotation: expr): 51 | self.class_self_id: str = class_self_id 52 | self.annotation: expr = annotation 53 | self.variables: List[Variable] = [] 54 | self.self_attributes: List[Variable] = [] 55 | 56 | def visit_Name(self, node: Name): 57 | """ 58 | Detects declarations of new variables 59 | """ 60 | if node.id != self.class_self_id: 61 | self.variables.append(Variable(node.id, self.annotation)) 62 | 63 | def visit_Attribute(self, node: Attribute): 64 | """ 65 | Detects declarations of new attributes on 'self' 66 | """ 67 | if isinstance(node.value, Name) and node.value.id == self.class_self_id: 68 | self.self_attributes.append(Variable(node.attr, self.annotation)) 69 | 70 | def visit_Subscript(self, node: Subscript): 71 | """ 72 | Assigns a value to a subscript of an existing variable: must be skipped 73 | """ 74 | pass 75 | 76 | 77 | class ConstructorVisitor(NodeVisitor): 78 | """ 79 | Identifies the attributes (and infer their type) assigned to self in the body of a constructor method 80 | """ 81 | 82 | def __init__( 83 | self, constructor_source: str, class_name: str, root_fqn: str, module_resolver: ModuleResolver, *args, **kwargs 84 | ): 85 | super().__init__(*args, **kwargs) 86 | self.constructor_source = constructor_source 87 | self.class_fqn: str = f'{module_resolver.module.__name__}.{class_name}' 88 | self.root_fqn = root_fqn 89 | self.module_resolver = module_resolver 90 | self.class_self_id: str 91 | self.variables_namespace: List[Variable] = [] 92 | self.uml_attributes: List[UmlAttribute] = [] 93 | self.uml_relations_by_target_fqn: Dict[str, UmlRelation] = {} 94 | 95 | def extend_relations(self, target_fqns: List[str]): 96 | self.uml_relations_by_target_fqn.update( 97 | { 98 | target_fqn: UmlRelation(self.class_fqn, target_fqn, RelType.COMPOSITION) 99 | for target_fqn in target_fqns 100 | if target_fqn.startswith(self.root_fqn) and (target_fqn not in self.uml_relations_by_target_fqn) 101 | } 102 | ) 103 | 104 | def get_from_namespace(self, variable_id: str) -> Variable: 105 | return next( 106 | ( 107 | variable 108 | # variables namespace is iterated antichronologically 109 | # to account for variables being overridden 110 | for variable in self.variables_namespace[::-1] 111 | if variable.id == variable_id 112 | ), 113 | None, 114 | ) 115 | 116 | def generic_visit(self, node): 117 | NodeVisitor.generic_visit(self, node) 118 | 119 | def visit_FunctionDef(self, node: FunctionDef): 120 | # retrieves constructor arguments ('self' reference and typed arguments) 121 | if node.name == '__init__': 122 | variables_collector = SignatureVariablesCollector(self.constructor_source) 123 | variables_collector.visit(node) 124 | self.class_self_id: str = variables_collector.class_self_id 125 | self.variables_namespace = variables_collector.variables 126 | 127 | self.generic_visit(node) 128 | 129 | def visit_AnnAssign(self, node: AnnAssign): 130 | variables_collector = AssignedVariablesCollector(self.class_self_id, node.annotation) 131 | variables_collector.visit(node.target) 132 | 133 | short_type, full_namespaced_definitions = self.derive_type_annotation_details(node.annotation) 134 | # if any, there is at most one self-assignment 135 | for variable in variables_collector.self_attributes: 136 | self.uml_attributes.append(UmlAttribute(variable.id, short_type, static=False)) 137 | self.extend_relations(full_namespaced_definitions) 138 | 139 | # if any, there is at most one typed variable added to the scope 140 | self.variables_namespace.extend(variables_collector.variables) 141 | 142 | def visit_Assign(self, node: Assign): 143 | # recipients of the assignment 144 | for assigned_target in node.targets: 145 | variables_collector = AssignedVariablesCollector(self.class_self_id, None) 146 | variables_collector.visit(assigned_target) 147 | 148 | # attempts to infer attribute type when a single attribute is assigned to a variable 149 | if (len(variables_collector.self_attributes) == 1) and (isinstance(node.value, Name)): 150 | assigned_variable = self.get_from_namespace(node.value.id) 151 | if assigned_variable is not None: 152 | short_type, full_namespaced_definitions = self.derive_type_annotation_details( 153 | assigned_variable.type_expr 154 | ) 155 | self.uml_attributes.append( 156 | UmlAttribute(variables_collector.self_attributes[0].id, short_type, False) 157 | ) 158 | self.extend_relations(full_namespaced_definitions) 159 | 160 | else: 161 | for variable in variables_collector.self_attributes: 162 | short_type, full_namespaced_definitions = self.derive_type_annotation_details(variable.type_expr) 163 | self.uml_attributes.append(UmlAttribute(variable.id, short_type, static=False)) 164 | self.extend_relations(full_namespaced_definitions) 165 | 166 | # other assignments were done in new variables that can shadow existing ones 167 | self.variables_namespace.extend(variables_collector.variables) 168 | 169 | def derive_type_annotation_details(self, annotation: expr) -> Tuple[str, List[str]]: 170 | """ 171 | From a type annotation, derives: 172 | - a short version of the type (withenum.TimeUnit -> TimeUnit, Tuple[withenum.TimeUnit] -> Tuple[TimeUnit]) 173 | - a list of the full-namespaced definitions involved in the type annotation (in order to build the relationships) 174 | """ 175 | if annotation is None: 176 | return None, [] 177 | 178 | # primitive type, object definition 179 | if isinstance(annotation, Name): 180 | full_namespaced_type, short_type = self.module_resolver.resolve_full_namespace_type(annotation.id) 181 | return short_type, [full_namespaced_type] 182 | # definition from module 183 | elif isinstance(annotation, Attribute): 184 | full_namespaced_type, short_type = self.module_resolver.resolve_full_namespace_type( 185 | get_source_segment(self.constructor_source, annotation) 186 | ) 187 | return short_type, [full_namespaced_type] 188 | # compound type (List[...], Tuple[Dict[str, float], module.DomainType], etc.) or '|'-based union type 189 | elif isinstance(annotation, (Subscript, BinOp)): 190 | return shorten_compound_type_annotation( 191 | get_source_segment(self.constructor_source, annotation), self.module_resolver 192 | ) 193 | 194 | return None, [] 195 | 196 | 197 | def shorten_compound_type_annotation(type_annotation: str, module_resolver: ModuleResolver) -> Tuple[str, List[str]]: 198 | """ 199 | In the string representation of a compound type annotation, the elementary types can be prefixed by their packages or sub-packages. 200 | Like in 'Dict[datetime.datetime,typing.List[Worker]]'. This function returns a tuple of 2 values: 201 | - a string representation with shortened types for display purposes in the PlantUML documentation: 'Dict[datetime, List[Worker]]' 202 | (note: a space is inserted after each coma for readability sake) 203 | - a list of the fully-qualified types involved in the annotation: ['typing.Dict', 'datetime.datetime', 'typing.List', 'mymodule.Worker'] 204 | """ 205 | compound_type_parts: List[str] = CompoundTypeSplitter(type_annotation, module_resolver.module.__name__).get_parts() 206 | compound_short_type_parts: List[str] = [] 207 | associated_types: List[str] = [] 208 | for compound_type_part in compound_type_parts: 209 | if compound_type_part in SPLITTING_CHARACTERS: 210 | if compound_type_part == ',': 211 | compound_short_type_parts.append(', ') 212 | elif compound_type_part == '|': 213 | compound_short_type_parts.append(' | ') 214 | else: 215 | compound_short_type_parts.append(compound_type_part) 216 | # replaces each type definition by its short class name 217 | else: 218 | full_namespaced_type, short_type = module_resolver.resolve_full_namespace_type(compound_type_part) 219 | if short_type is None: 220 | raise ValueError( 221 | f'Could not resolve type {compound_type_part} in module {module_resolver.module}: it needs to be imported explicitly.' 222 | ) 223 | else: 224 | compound_short_type_parts.append(short_type) 225 | associated_types.append(full_namespaced_type) 226 | 227 | return ''.join(compound_short_type_parts), associated_types 228 | -------------------------------------------------------------------------------- /tests/py2puml/parsing/test_astvisitors.py: -------------------------------------------------------------------------------- 1 | from ast import AST, get_source_segment, parse 2 | from inspect import getsource 3 | from textwrap import dedent 4 | from typing import Dict, List, Tuple, Union 5 | 6 | from pytest import mark 7 | 8 | from py2puml.parsing.astvisitors import ( 9 | AssignedVariablesCollector, 10 | SignatureVariablesCollector, 11 | shorten_compound_type_annotation, 12 | ) 13 | from py2puml.parsing.moduleresolver import ModuleResolver 14 | 15 | from tests.asserts.variable import assert_Variable 16 | from tests.py2puml.parsing.mockedinstance import MockedInstance 17 | 18 | 19 | class ParseMyConstructorArguments: 20 | def __init__( 21 | # the reference to the instance is often 'self' by convention, but can be anything else 22 | me, 23 | # some arguments, typed or untyped 24 | an_int: int, 25 | an_untyped, 26 | a_compound_type: Tuple[float, Dict[str, List[bool]]], 27 | # union types 28 | x_a: Union[int, float], 29 | y_a: Union[int, float, None], 30 | x_b: int | float, 31 | y_b: int | float | None, 32 | # arbitrary-length homogenous tuple type 33 | bb: Tuple[int, ...], 34 | # an argument with a default value 35 | a_default_string: str = 'text', 36 | # positional and keyword wildcard arguments 37 | *args, 38 | **kwargs, 39 | ): 40 | pass 41 | 42 | 43 | def test_SignatureVariablesCollector_collect_arguments(): 44 | constructor_source: str = dedent(getsource(ParseMyConstructorArguments.__init__.__code__)) 45 | constructor_ast: AST = parse(constructor_source) 46 | 47 | collector = SignatureVariablesCollector(constructor_source) 48 | collector.visit(constructor_ast) 49 | 50 | assert collector.class_self_id == 'me' 51 | assert len(collector.variables) == 11, 'all the arguments must be detected' 52 | assert_Variable(collector.variables[0], 'an_int', 'int', constructor_source) 53 | assert_Variable(collector.variables[1], 'an_untyped', None, constructor_source) 54 | assert_Variable( 55 | collector.variables[2], 'a_compound_type', 'Tuple[float, Dict[str, List[bool]]]', constructor_source 56 | ) 57 | 58 | assert_Variable(collector.variables[3], 'x_a', 'Union[int, float]', constructor_source) 59 | assert_Variable(collector.variables[4], 'y_a', 'Union[int, float, None]', constructor_source) 60 | assert_Variable(collector.variables[5], 'x_b', 'int | float', constructor_source) 61 | assert_Variable(collector.variables[6], 'y_b', 'int | float | None', constructor_source) 62 | 63 | assert_Variable(collector.variables[7], 'bb', 'Tuple[int, ...]', constructor_source) 64 | assert_Variable(collector.variables[8], 'a_default_string', 'str', constructor_source) 65 | assert_Variable(collector.variables[9], 'args', None, constructor_source) 66 | assert_Variable(collector.variables[10], 'kwargs', None, constructor_source) 67 | 68 | 69 | @mark.parametrize( 70 | ['class_self_id', 'assignment_code', 'annotation_as_str', 'self_attributes', 'variables'], 71 | [ 72 | # detects the assignment to a new variable 73 | ('self', 'my_var = 5', None, [], [('my_var', None)]), 74 | ('self', 'my_var: int = 5', 'int', [], [('my_var', 'int')]), 75 | # detects the assignment to a new self attribute 76 | ('self', 'self.my_attr = 6', None, [('my_attr', None)], []), 77 | ('self', 'self.my_attr: int = 6', 'int', [('my_attr', 'int')], []), 78 | # tuple assignment mixing variable and attribute 79 | ('self', 'my_var, self.my_attr = 5, 6', None, [('my_attr', None)], [('my_var', None)]), 80 | # assignment to a subscript of an attribute 81 | ('self', 'self.my_attr[0] = 0', None, [], []), 82 | ('self', 'self.my_attr[0]:int = 0', 'int', [], []), 83 | # assignment to an attribute of an attribute 84 | ('self', 'self.my_attr.id = "42"', None, [], []), 85 | ('self', 'self.my_attr.id: str = "42"', 'str', [], []), 86 | # assignment to an attribute of a reference which is not 'self' 87 | ('me', 'self.my_attr = 6', None, [], []), 88 | ('me', 'self.my_attr: int = 6', 'int', [], []), 89 | ], 90 | ) 91 | def test_AssignedVariablesCollector_single_assignment_separate_variable_from_instance_attribute( 92 | class_self_id: str, assignment_code: str, annotation_as_str: str, self_attributes: list, variables: list 93 | ): 94 | # the assignment is the first line of the body 95 | assignment_ast: AST = parse(assignment_code).body[0] 96 | 97 | # assignment without annotation (multiple targets, but only one in these test cases) 98 | if annotation_as_str is None: 99 | annotation = None 100 | assert len(assignment_ast.targets) == 1, 'unit test consistency' 101 | assignment_target = assignment_ast.targets[0] 102 | # assignment with annotation (only one target) 103 | else: 104 | annotation = assignment_ast.annotation 105 | assert get_source_segment(assignment_code, annotation) == annotation_as_str, 'unit test consistency' 106 | assignment_target = assignment_ast.target 107 | 108 | assignment_collector = AssignedVariablesCollector(class_self_id, annotation) 109 | assignment_collector.visit(assignment_target) 110 | 111 | # detection of self attributes 112 | assert len(assignment_collector.self_attributes) == len(self_attributes) 113 | for self_attribute, (variable_id, variable_type_str) in zip(assignment_collector.self_attributes, self_attributes): 114 | assert_Variable(self_attribute, variable_id, variable_type_str, assignment_code) 115 | 116 | # detection of new variables occupying the memory scope 117 | assert len(assignment_collector.variables) == len(variables) 118 | for variable, (variable_id, variable_type_str) in zip(assignment_collector.variables, variables): 119 | assert_Variable(variable, variable_id, variable_type_str, assignment_code) 120 | 121 | 122 | @mark.parametrize( 123 | ['class_self_id', 'assignment_code', 'self_attributes_and_variables_by_target'], 124 | [ 125 | ( 126 | 'self', 127 | 'x = y = 0', 128 | [ 129 | ([], ['x']), 130 | ([], ['y']), 131 | ], 132 | ), 133 | ( 134 | 'self', 135 | 'self.x = self.y = 0', 136 | [ 137 | (['x'], []), 138 | (['y'], []), 139 | ], 140 | ), 141 | ( 142 | 'self', 143 | 'self.my_attr = self.my_list[0] = 5', 144 | [ 145 | (['my_attr'], []), 146 | ([], []), 147 | ], 148 | ), 149 | ( 150 | 'self', 151 | 'self.x, self.y = self.origin = (0, 0)', 152 | [ 153 | (['x', 'y'], []), 154 | (['origin'], []), 155 | ], 156 | ), 157 | ], 158 | ) 159 | def test_AssignedVariablesCollector_multiple_assignments_separate_variable_from_instance_attribute( 160 | class_self_id: str, assignment_code: str, self_attributes_and_variables_by_target: tuple 161 | ): 162 | # the assignment is the first line of the body 163 | assignment_ast: AST = parse(assignment_code).body[0] 164 | 165 | assert len(assignment_ast.targets) == len(self_attributes_and_variables_by_target), ( 166 | 'test consistency: all targets must be tested' 167 | ) 168 | for assignment_target, (self_attribute_ids, variable_ids) in zip( 169 | assignment_ast.targets, self_attributes_and_variables_by_target 170 | ): 171 | assignment_collector = AssignedVariablesCollector(class_self_id, None) 172 | assignment_collector.visit(assignment_target) 173 | 174 | assert len(assignment_collector.self_attributes) == len(self_attribute_ids), 'test consistency' 175 | for self_attribute, self_attribute_id in zip(assignment_collector.self_attributes, self_attribute_ids): 176 | assert self_attribute.id == self_attribute_id 177 | assert self_attribute.type_expr is None, 'Python does not allow type annotation in multiple assignment' 178 | 179 | assert len(assignment_collector.variables) == len(variable_ids), 'test consistency' 180 | for variable, variable_id in zip(assignment_collector.variables, variable_ids): 181 | assert variable.id == variable_id 182 | assert variable.type_expr is None, 'Python does not allow type annotation in multiple assignment' 183 | 184 | 185 | @mark.parametrize( 186 | ['full_annotation', 'short_annotation', 'namespaced_definitions', 'module_dict'], 187 | [ 188 | ( 189 | # domain.people was imported, people.Person is used 190 | 'people.Person', 191 | 'Person', 192 | ['domain.people.Person'], 193 | {'__name__': 'testmodule', 'people': {'Person': {'__module__': 'domain.people', '__name__': 'Person'}}}, 194 | ), 195 | ( 196 | # combination of compound types 197 | 'Dict[id.Identifier,typing.List[domain.Person]]', 198 | 'Dict[Identifier, List[Person]]', 199 | ['typing.Dict', 'id.Identifier', 'typing.List', 'domain.Person'], 200 | { 201 | '__name__': 'testmodule', 202 | 'Dict': Dict, 203 | 'List': List, 204 | 'id': { 205 | 'Identifier': { 206 | '__module__': 'id', 207 | '__name__': 'Identifier', 208 | } 209 | }, 210 | 'domain': { 211 | 'Person': { 212 | '__module__': 'domain', 213 | '__name__': 'Person', 214 | } 215 | }, 216 | }, 217 | ), 218 | ( 219 | # union types 220 | 'Dict[typing.Union[int,id.Identifier],str|domain.Person]', 221 | 'Dict[Union[int, Identifier], str | Person]', 222 | ['typing.Dict', 'typing.Union', 'builtins.int', 'id.Identifier', 'builtins.str', 'domain.Person'], 223 | { 224 | '__name__': 'testmodule', 225 | '__builtins__': { 226 | 'int': { 227 | '__module__': 'builtins', 228 | '__name__': 'int', 229 | }, 230 | 'str': { 231 | '__module__': 'builtins', 232 | '__name__': 'str', 233 | }, 234 | }, 235 | 'Dict': Dict, 236 | 'List': List, 237 | 'Union': Union, 238 | 'id': { 239 | 'Identifier': { 240 | '__module__': 'id', 241 | '__name__': 'Identifier', 242 | } 243 | }, 244 | 'domain': { 245 | 'Person': { 246 | '__module__': 'domain', 247 | '__name__': 'Person', 248 | } 249 | }, 250 | }, 251 | ), 252 | ( 253 | # new test case for Tuple[int, ...] 254 | 'typing.Tuple[int, ...]', 255 | 'Tuple[int, ...]', 256 | ['typing.Tuple', 'builtins.int'], 257 | { 258 | '__name__': 'testmodule', 259 | '__builtins__': { 260 | 'int': { 261 | '__module__': 'builtins', 262 | '__name__': 'int', 263 | }, 264 | }, 265 | 'Tuple': Tuple, 266 | }, 267 | ), 268 | ], 269 | ) 270 | def test_shorten_compound_type_annotation( 271 | full_annotation: str, short_annotation: str, namespaced_definitions: List[str], module_dict: dict 272 | ): 273 | module_resolver = ModuleResolver(MockedInstance(module_dict)) 274 | shortened_annotation, full_namespaced_definitions = shorten_compound_type_annotation( 275 | full_annotation, module_resolver 276 | ) 277 | assert shortened_annotation == short_annotation 278 | assert full_namespaced_definitions == namespaced_definitions 279 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
11 | 12 | Generate PlantUML class diagrams to document your Python application. 13 | 14 | [](https://results.pre-commit.ci/latest/github/lucsorel/py2puml/main) 15 | 16 | 17 | `py2puml` uses [pre-commit hooks](https://pre-commit.com/) and [pre-commit.ci Continuous Integration](https://pre-commit.ci/) to enforce commit messages, code formatting and linting for quality and consistency sake. 18 | See the [code conventions](#code-conventions) section if you would like to contribute to the project. 19 | 20 | 21 | # How it works 22 | 23 | `py2puml` produces a class diagram [PlantUML script](https://plantuml.com/en/class-diagram) representing classes properties (static and instance attributes) and their relations (composition and inheritance relationships). 24 | 25 | `py2puml` internally uses code [inspection](https://docs.python.org/3/library/inspect.html) (also called *reflexion* in other programming languages) and [abstract tree parsing](https://docs.python.org/3/library/ast.html) to retrieve relevant information. 26 | 27 | ## Minimum Python versions to run py2puml 28 | 29 | `p2puml` uses some code-parsing features that are available only since **Python 3.8** (like [ast.get_source_segment](https://docs.python.org/3/library/ast.html#ast.get_source_segment)). 30 | If your codebase uses the `int | float` syntax to define optional types, then you should use Python 3.10 to run `py2puml`. 31 | 32 | To sum it up, use at least Python 3.8 to run py2puml, or a higher version if you use syntax features available only in higher versions. 33 | 34 | The [.python-version](.python-version) file indicates the Python version used to develop the library. 35 | It is a file used by [pyenv](https://github.com/pyenv/pyenv/) to define the binary used by the project. 36 | 37 | ## Features 38 | 39 | From a given path corresponding to a folder containing Python code, `py2puml` processes each Python module and generates a [PlantUML script](https://plantuml.com/en/class-diagram) from the definitions of various data structures using: 40 | 41 | * **[inspection](https://docs.python.org/3/library/inspect.html)** and [type annotations](https://docs.python.org/3/library/typing.html) to detect: 42 | * static class attributes and [dataclass](https://docs.python.org/3/library/dataclasses.html) fields 43 | * fields of [namedtuples](https://docs.python.org/3/library/collections.html#collections.namedtuple) 44 | * members of [enumerations](https://docs.python.org/3/library/enum.html) 45 | * composition and inheritance relationships (between your domain classes only, for documentation sake). 46 | The detection of composition relationships relies on type annotations only, assigned values or expressions are never evaluated to prevent unwanted side-effects 47 | 48 | * parsing **[abstract syntax trees](https://docs.python.org/3/library/ast.html#ast.NodeVisitor)** to detect the instance attributes defined in `__init__` constructors 49 | 50 | `py2puml` outputs diagrams in PlantUML syntax, which can be: 51 | * versioned along your code with a unit-test ensuring its consistency (see the [test_py2puml.py's test_py2puml_model_on_py2uml_domain](tests/py2puml/test_py2puml.py) example) 52 | * generated and hosted along other code documentation (better option: generated documentation should not be versioned with the codebase) 53 | 54 | To generate image files, use the PlantUML runtime, a docker image of the runtime (see [think/plantuml](https://hub.docker.com/r/think/plantuml)) or of a server (see the CLI documentation below) 55 | 56 | If you like tools related with PlantUML, you may also be interested in this [lucsorel/plantuml-file-loader](https://github.com/lucsorel/plantuml-file-loader) project: 57 | a webpack loader which converts PlantUML files into images during the webpack processing (useful to [include PlantUML diagrams in your slides](https://github.com/lucsorel/markdown-image-loader/blob/master/README.md#web-based-slideshows) with RevealJS or RemarkJS). 58 | 59 | # Install 60 | 61 | Install from [PyPI](https://pypi.org/project/py2puml/): 62 | 63 | * with `pip`: 64 | 65 | ```sh 66 | pip install py2puml 67 | ``` 68 | 69 | * with [poetry](https://python-poetry.org/docs/): 70 | 71 | ```sh 72 | poetry add py2puml 73 | ``` 74 | 75 | * with [pipenv](https://pipenv.readthedocs.io/en/latest/): 76 | 77 | ```sh 78 | pipenv install py2puml 79 | ``` 80 | 81 | # Usage 82 | 83 | ## CLI 84 | 85 | Once `py2puml` is installed at the system level, an eponymous command is available in your environment shell. 86 | 87 | For example, to create the diagram of the classes used by `py2puml`, run: 88 | 89 | ```sh 90 | py2puml py2puml/domain py2puml.domain 91 | ``` 92 | 93 | This outputs the following PlantUML content: 94 | 95 | ```plantuml 96 | @startuml py2puml.domain 97 | !pragma useIntermediatePackages false 98 | 99 | class py2puml.domain.umlclass.UmlAttribute { 100 | name: str 101 | type: str 102 | static: bool 103 | } 104 | class py2puml.domain.umlclass.UmlClass { 105 | attributes: List[UmlAttribute] 106 | is_abstract: bool 107 | } 108 | class py2puml.domain.umlitem.UmlItem { 109 | name: str 110 | fqn: str 111 | } 112 | class py2puml.domain.umlenum.Member { 113 | name: str 114 | value: str 115 | } 116 | class py2puml.domain.umlenum.UmlEnum { 117 | members: List[Member] 118 | } 119 | enum py2puml.domain.umlrelation.RelType { 120 | COMPOSITION: * {static} 121 | INHERITANCE: <| {static} 122 | } 123 | class py2puml.domain.umlrelation.UmlRelation { 124 | source_fqn: str 125 | target_fqn: str 126 | type: RelType 127 | } 128 | py2puml.domain.umlclass.UmlClass *-- py2puml.domain.umlclass.UmlAttribute 129 | py2puml.domain.umlitem.UmlItem <|-- py2puml.domain.umlclass.UmlClass 130 | py2puml.domain.umlenum.UmlEnum *-- py2puml.domain.umlenum.Member 131 | py2puml.domain.umlitem.UmlItem <|-- py2puml.domain.umlenum.UmlEnum 132 | py2puml.domain.umlrelation.UmlRelation *-- py2puml.domain.umlrelation.RelType 133 | footer Generated by //py2puml// 134 | @enduml 135 | ``` 136 | 137 | Using PlantUML, this content is rendered as in this diagram: 138 | 139 |  140 | 141 | For a full overview of the CLI, run: 142 | 143 | ```sh 144 | py2puml --help 145 | ``` 146 | 147 | The CLI can also be launched as a python module: 148 | 149 | ```sh 150 | python -m py2puml py2puml/domain py2puml.domain 151 | ``` 152 | 153 | Pipe the result of the CLI with a PlantUML server for instantaneous documentation (rendered by ImageMagick): 154 | 155 | ```sh 156 | # runs a local PlantUML server from a docker container: 157 | docker run -d --rm -p 1234:8080 --name plantumlserver plantuml/plantuml-server:jetty 158 | 159 | py2puml py2puml/domain py2puml.domain | curl -X POST --data-binary @- http://localhost:1234/svg/ --output - | display 160 | 161 | # stops the container when you don't need it anymore, restarts it later 162 | docker stop plantumlserver 163 | docker start plantumlserver 164 | ``` 165 | 166 | ## Python API 167 | 168 | For example, to create the diagram of the domain classes used by `py2puml`: 169 | 170 | * import the `py2puml` function in your script: 171 | 172 | ```python 173 | from py2puml.py2puml import py2puml 174 | 175 | if __name__ == '__main__': 176 | # outputs the PlantUML content in the terminal 177 | print(''.join(py2puml('py2puml/domain', 'py2puml.domain'))) 178 | 179 | # writes the PlantUML content in a file 180 | with open('py2puml/py2puml.domain.puml', 'w', encoding='utf8') as puml_file: 181 | puml_file.writelines(py2puml('py2puml/domain', 'py2puml.domain')) 182 | ``` 183 | 184 | * running it outputs the previous PlantUML diagram in the terminal and writes it in a file. 185 | 186 | 187 | # Tests 188 | 189 | ```sh 190 | # directly with poetry 191 | poetry run pytest -v 192 | 193 | # in a virtual environment 194 | python3 -m pytest -v 195 | ``` 196 | 197 | Code coverage (with [missed branch statements](https://pytest-cov.readthedocs.io/en/latest/config.html?highlight=--cov-branch)): 198 | 199 | ```sh 200 | poetry run pytest -v --cov=py2puml --cov-branch --cov-report term-missing --cov-fail-under 93 201 | ``` 202 | 203 | # Changelog 204 | 205 | * `0.10.0`: support ellipsis in type annotations (tuple with arbitrary length) 206 | * `0.9.1`: improved 0.7.2 by adding the current working directory at the beginning of the sys.path to resolve the module paths of the project being inspected. 207 | Fix url to PlantUML logo on the README.md page 208 | * `0.9.0`: add classes defined in `__init__.py` files to plantuml output; replaced yapf by the ruff formatter 209 | * `0.8.1`: delegated the grouping of nested namespaces (see `0.7.0`) to the PlantUML binary, which handles it natively 210 | * `0.8.0`: added support for union types, and github actions (pre-commit hooks + automated tests) 211 | * `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) 212 | * `0.7.1`: removed obsolete part of documentation: deeply compound types are now well handled (by version `0.7.0`) 213 | * `0.7.0`: improved the generated PlantUML documentation (added the namespace structure of the code base, homogenized type between inspection and parsing), improved relationships management (handle forward references, deduplicate relationships) 214 | * `0.6.1`: handle class names with digits 215 | * `0.6.0`: handle abstract classes 216 | * `0.5.4`: fixed the packaging so that the contribution guide is included in the published package 217 | * `0.5.3`: handle constructors decorated by wrapping decorators (decorators making uses of `functools.wrap`) 218 | * `0.5.2`: specify in pyproject.toml that py2puml requires python 3.8+ (`ast.get_source_segment` was introduced in 3.8) 219 | * `0.5.1`: prevent from parsing inherited constructors 220 | * `0.5.0`: handle instance attributes in class constructors, add code coverage of unit tests 221 | * `0.4.0`: add a simple CLI 222 | * `0.3.1`: inspect sub-folders recursively 223 | * `0.3.0`: handle classes derived from namedtuples (attribute types are `Any`) 224 | * `0.2.0`: handle inheritance relationships and enums 225 | * `0.1.3`: first release, handle all modules of a folder and compositions of domain classes 226 | 227 | # Licence 228 | 229 | Unless stated otherwise all works are licensed under the [MIT license](http://spdx.org/licenses/MIT.html), a copy of which is included [here](LICENSE). 230 | 231 | # Contributions 232 | 233 | I'm thankful to [all the people who have contributed](https://github.com/lucsorel/py2puml/graphs/contributors) to this project: 234 | 235 |  236 | 237 | * [Luc Sorel-Giffo](https://github.com/lucsorel) 238 | * [Doyou Jung](https://github.com/doyou89) 239 | * [Julien Jerphanion](https://github.com/jjerphan) 240 | * [Luis Fernando Villanueva Pérez](https://github.com/jonykalavera) 241 | * [Konstantin Zangerle](https://github.com/justkiddingcode) 242 | * [Mieszko](https://github.com/0xmzk) 243 | 244 | ## Pull requests 245 | 246 | Pull-requests are welcome and will be processed on a best-effort basis. 247 | 248 | Pull requests must follow the guidelines enforced by the `pre-commit` hooks: 249 | 250 | - commit messages must follow the Angular conventions enforced by the `commitlint` hook 251 | - code formatting must follow the conventions enforced by the `isort` and `ruff-format` hooks 252 | - code linting should not detect code smells in your contributions, this is checked by the `ruff` hook 253 | 254 | Please also follow the [contributing guide](CONTRIBUTING.md) to ease your contribution. 255 | 256 | ## Code conventions 257 | 258 | The code conventions are described and enforced by [pre-commit hooks](https://pre-commit.com/hooks.html) to maintain consistency across the code base. 259 | The hooks are declared in the [.pre-commit-config.yaml](.pre-commit-config.yaml) file. 260 | 261 | Set the git hooks (pre-commit and commit-msg types): 262 | 263 | ```sh 264 | poetry run pre-commit install --hook-type pre-commit --hook-type commit-msg 265 | ``` 266 | 267 | Before committing, you can check your changes with: 268 | 269 | ```sh 270 | # put all your changes in the git staging area 271 | git add -A 272 | 273 | # all hooks 274 | poetry run pre-commit run --all-files 275 | 276 | # a specific hook 277 | poetry run pre-commit run ruff --all-files 278 | ``` 279 | 280 | ### Commit messages 281 | 282 | Please, follow the [conventions of the Angular team](https://github.com/angular/angular/blob/main/CONTRIBUTING.md#-commit-message-format) for commit messages. 283 | When merging your pull-request, the new version of the project will be derived from the messages. 284 | 285 | ### Code formatting 286 | 287 | This project uses `isort` and `ruff-format` to format the code. 288 | The guidelines are expressed in their respective sections in the [pyproject.toml](pyproject.toml) file. 289 | 290 | ### Best practices 291 | 292 | This project uses the `ruff` linter, which is configured in its section in the [pyproject.toml](pyproject.toml) file. 293 | 294 | # Current limitations 295 | 296 | * regarding **inspection** 297 | 298 | * type hinting is optional when writing Python code and discarded when it is executed, as mentionned in the [typing official documentation](https://docs.python.org/3/library/typing.html). The quality of the diagram output by `py2puml` depends on the reliability with which the type annotations were written 299 | 300 | > The Python runtime does not enforce function and variable type annotations. They can be used by third party tools such as type checkers, IDEs, linters, etc. 301 | 302 | * regarding the detection of instance attributes with **AST parsing**: 303 | * only constructors are visited, attributes assigned in other functions won't be documented 304 | * attribute types are inferred from type annotations: 305 | * of the attribute itself 306 | * of the variable assigned to the attribute: a signature parameter or a locale variable 307 | * to avoid side-effects, no code is executed nor interpreted 308 | 309 | # Alternatives 310 | 311 | If `py2puml` does not meet your needs (suggestions and pull-requests are **welcome**), you can have a look at these projects which follow other approaches (AST, linting, modeling): 312 | 313 | * [pyreverse](https://pylint.pycqa.org/en/latest/additional_commands/index.html#pyreverse), which includes a PlantUML printer [since version 2.10.0](https://pylint.pycqa.org/en/latest/whatsnew/changelog.html?highlight=plantuml#what-s-new-in-pylint-2-10-0) 314 | * [cb109/pyplantuml](https://github.com/cb109/pyplantuml) 315 | * [deadbok/py-puml-tools](https://github.com/deadbok/py-puml-tools) 316 | * [caballero/genUML](https://github.com/jose-caballero/genUML) 317 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "cfgv" 5 | version = "3.4.0" 6 | description = "Validate configuration and produce human readable error messages." 7 | optional = false 8 | python-versions = ">=3.8" 9 | groups = ["lint"] 10 | files = [ 11 | {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, 12 | {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, 13 | ] 14 | 15 | [[package]] 16 | name = "colorama" 17 | version = "0.4.6" 18 | description = "Cross-platform colored terminal text." 19 | optional = false 20 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 21 | groups = ["dev"] 22 | markers = "sys_platform == \"win32\"" 23 | files = [ 24 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 25 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 26 | ] 27 | 28 | [[package]] 29 | name = "coverage" 30 | version = "7.4.0" 31 | description = "Code coverage measurement for Python" 32 | optional = false 33 | python-versions = ">=3.8" 34 | groups = ["dev"] 35 | files = [ 36 | {file = "coverage-7.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36b0ea8ab20d6a7564e89cb6135920bc9188fb5f1f7152e94e8300b7b189441a"}, 37 | {file = "coverage-7.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0676cd0ba581e514b7f726495ea75aba3eb20899d824636c6f59b0ed2f88c471"}, 38 | {file = "coverage-7.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ca5c71a5a1765a0f8f88022c52b6b8be740e512980362f7fdbb03725a0d6b9"}, 39 | {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7c97726520f784239f6c62506bc70e48d01ae71e9da128259d61ca5e9788516"}, 40 | {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:815ac2d0f3398a14286dc2cea223a6f338109f9ecf39a71160cd1628786bc6f5"}, 41 | {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:80b5ee39b7f0131ebec7968baa9b2309eddb35b8403d1869e08f024efd883566"}, 42 | {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5b2ccb7548a0b65974860a78c9ffe1173cfb5877460e5a229238d985565574ae"}, 43 | {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:995ea5c48c4ebfd898eacb098164b3cc826ba273b3049e4a889658548e321b43"}, 44 | {file = "coverage-7.4.0-cp310-cp310-win32.whl", hash = "sha256:79287fd95585ed36e83182794a57a46aeae0b64ca53929d1176db56aacc83451"}, 45 | {file = "coverage-7.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:5b14b4f8760006bfdb6e08667af7bc2d8d9bfdb648351915315ea17645347137"}, 46 | {file = "coverage-7.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04387a4a6ecb330c1878907ce0dc04078ea72a869263e53c72a1ba5bbdf380ca"}, 47 | {file = "coverage-7.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea81d8f9691bb53f4fb4db603203029643caffc82bf998ab5b59ca05560f4c06"}, 48 | {file = "coverage-7.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74775198b702868ec2d058cb92720a3c5a9177296f75bd97317c787daf711505"}, 49 | {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76f03940f9973bfaee8cfba70ac991825611b9aac047e5c80d499a44079ec0bc"}, 50 | {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:485e9f897cf4856a65a57c7f6ea3dc0d4e6c076c87311d4bc003f82cfe199d25"}, 51 | {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6ae8c9d301207e6856865867d762a4b6fd379c714fcc0607a84b92ee63feff70"}, 52 | {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bf477c355274a72435ceb140dc42de0dc1e1e0bf6e97195be30487d8eaaf1a09"}, 53 | {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:83c2dda2666fe32332f8e87481eed056c8b4d163fe18ecc690b02802d36a4d26"}, 54 | {file = "coverage-7.4.0-cp311-cp311-win32.whl", hash = "sha256:697d1317e5290a313ef0d369650cfee1a114abb6021fa239ca12b4849ebbd614"}, 55 | {file = "coverage-7.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:26776ff6c711d9d835557ee453082025d871e30b3fd6c27fcef14733f67f0590"}, 56 | {file = "coverage-7.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:13eaf476ec3e883fe3e5fe3707caeb88268a06284484a3daf8250259ef1ba143"}, 57 | {file = "coverage-7.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846f52f46e212affb5bcf131c952fb4075b55aae6b61adc9856222df89cbe3e2"}, 58 | {file = "coverage-7.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26f66da8695719ccf90e794ed567a1549bb2644a706b41e9f6eae6816b398c4a"}, 59 | {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:164fdcc3246c69a6526a59b744b62e303039a81e42cfbbdc171c91a8cc2f9446"}, 60 | {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:316543f71025a6565677d84bc4df2114e9b6a615aa39fb165d697dba06a54af9"}, 61 | {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bb1de682da0b824411e00a0d4da5a784ec6496b6850fdf8c865c1d68c0e318dd"}, 62 | {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0e8d06778e8fbffccfe96331a3946237f87b1e1d359d7fbe8b06b96c95a5407a"}, 63 | {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a56de34db7b7ff77056a37aedded01b2b98b508227d2d0979d373a9b5d353daa"}, 64 | {file = "coverage-7.4.0-cp312-cp312-win32.whl", hash = "sha256:51456e6fa099a8d9d91497202d9563a320513fcf59f33991b0661a4a6f2ad450"}, 65 | {file = "coverage-7.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd3c1e4cb2ff0083758f09be0f77402e1bdf704adb7f89108007300a6da587d0"}, 66 | {file = "coverage-7.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e9d1bf53c4c8de58d22e0e956a79a5b37f754ed1ffdbf1a260d9dcfa2d8a325e"}, 67 | {file = "coverage-7.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:109f5985182b6b81fe33323ab4707011875198c41964f014579cf82cebf2bb85"}, 68 | {file = "coverage-7.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc9d4bc55de8003663ec94c2f215d12d42ceea128da8f0f4036235a119c88ac"}, 69 | {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc6d65b21c219ec2072c1293c505cf36e4e913a3f936d80028993dd73c7906b1"}, 70 | {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a10a4920def78bbfff4eff8a05c51be03e42f1c3735be42d851f199144897ba"}, 71 | {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b8e99f06160602bc64da35158bb76c73522a4010f0649be44a4e167ff8555952"}, 72 | {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7d360587e64d006402b7116623cebf9d48893329ef035278969fa3bbf75b697e"}, 73 | {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:29f3abe810930311c0b5d1a7140f6395369c3db1be68345638c33eec07535105"}, 74 | {file = "coverage-7.4.0-cp38-cp38-win32.whl", hash = "sha256:5040148f4ec43644702e7b16ca864c5314ccb8ee0751ef617d49aa0e2d6bf4f2"}, 75 | {file = "coverage-7.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:9864463c1c2f9cb3b5db2cf1ff475eed2f0b4285c2aaf4d357b69959941aa555"}, 76 | {file = "coverage-7.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:936d38794044b26c99d3dd004d8af0035ac535b92090f7f2bb5aa9c8e2f5cd42"}, 77 | {file = "coverage-7.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:799c8f873794a08cdf216aa5d0531c6a3747793b70c53f70e98259720a6fe2d7"}, 78 | {file = "coverage-7.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7defbb9737274023e2d7af02cac77043c86ce88a907c58f42b580a97d5bcca9"}, 79 | {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1526d265743fb49363974b7aa8d5899ff64ee07df47dd8d3e37dcc0818f09ed"}, 80 | {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf635a52fc1ea401baf88843ae8708591aa4adff875e5c23220de43b1ccf575c"}, 81 | {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:756ded44f47f330666843b5781be126ab57bb57c22adbb07d83f6b519783b870"}, 82 | {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0eb3c2f32dabe3a4aaf6441dde94f35687224dfd7eb2a7f47f3fd9428e421058"}, 83 | {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bfd5db349d15c08311702611f3dccbef4b4e2ec148fcc636cf8739519b4a5c0f"}, 84 | {file = "coverage-7.4.0-cp39-cp39-win32.whl", hash = "sha256:53d7d9158ee03956e0eadac38dfa1ec8068431ef8058fe6447043db1fb40d932"}, 85 | {file = "coverage-7.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:cfd2a8b6b0d8e66e944d47cdec2f47c48fef2ba2f2dff5a9a75757f64172857e"}, 86 | {file = "coverage-7.4.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:c530833afc4707fe48524a44844493f36d8727f04dcce91fb978c414a8556cc6"}, 87 | {file = "coverage-7.4.0.tar.gz", hash = "sha256:707c0f58cb1712b8809ece32b68996ee1e609f71bd14615bd8f87a1293cb610e"}, 88 | ] 89 | 90 | [package.dependencies] 91 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 92 | 93 | [package.extras] 94 | toml = ["tomli ; python_full_version <= \"3.11.0a6\""] 95 | 96 | [[package]] 97 | name = "distlib" 98 | version = "0.3.8" 99 | description = "Distribution utilities" 100 | optional = false 101 | python-versions = "*" 102 | groups = ["dev", "lint"] 103 | files = [ 104 | {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, 105 | {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, 106 | ] 107 | 108 | [[package]] 109 | name = "exceptiongroup" 110 | version = "1.2.0" 111 | description = "Backport of PEP 654 (exception groups)" 112 | optional = false 113 | python-versions = ">=3.7" 114 | groups = ["dev"] 115 | markers = "python_version < \"3.11\"" 116 | files = [ 117 | {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, 118 | {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, 119 | ] 120 | 121 | [package.extras] 122 | test = ["pytest (>=6)"] 123 | 124 | [[package]] 125 | name = "filelock" 126 | version = "3.13.1" 127 | description = "A platform independent file lock." 128 | optional = false 129 | python-versions = ">=3.8" 130 | groups = ["dev", "lint"] 131 | files = [ 132 | {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, 133 | {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, 134 | ] 135 | 136 | [package.extras] 137 | docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] 138 | testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] 139 | typing = ["typing-extensions (>=4.8) ; python_version < \"3.11\""] 140 | 141 | [[package]] 142 | name = "identify" 143 | version = "2.5.33" 144 | description = "File identification library for Python" 145 | optional = false 146 | python-versions = ">=3.8" 147 | groups = ["lint"] 148 | files = [ 149 | {file = "identify-2.5.33-py2.py3-none-any.whl", hash = "sha256:d40ce5fcd762817627670da8a7d8d8e65f24342d14539c59488dc603bf662e34"}, 150 | {file = "identify-2.5.33.tar.gz", hash = "sha256:161558f9fe4559e1557e1bff323e8631f6a0e4837f7497767c1782832f16b62d"}, 151 | ] 152 | 153 | [package.extras] 154 | license = ["ukkonen"] 155 | 156 | [[package]] 157 | name = "iniconfig" 158 | version = "2.0.0" 159 | description = "brain-dead simple config-ini parsing" 160 | optional = false 161 | python-versions = ">=3.7" 162 | groups = ["dev"] 163 | files = [ 164 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 165 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 166 | ] 167 | 168 | [[package]] 169 | name = "nodeenv" 170 | version = "1.8.0" 171 | description = "Node.js virtual environment builder" 172 | optional = false 173 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" 174 | groups = ["lint"] 175 | files = [ 176 | {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, 177 | {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, 178 | ] 179 | 180 | [package.dependencies] 181 | setuptools = "*" 182 | 183 | [[package]] 184 | name = "packaging" 185 | version = "23.2" 186 | description = "Core utilities for Python packages" 187 | optional = false 188 | python-versions = ">=3.7" 189 | groups = ["dev"] 190 | files = [ 191 | {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, 192 | {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, 193 | ] 194 | 195 | [[package]] 196 | name = "platformdirs" 197 | version = "4.1.0" 198 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 199 | optional = false 200 | python-versions = ">=3.8" 201 | groups = ["dev", "lint"] 202 | files = [ 203 | {file = "platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380"}, 204 | {file = "platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420"}, 205 | ] 206 | 207 | [package.extras] 208 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] 209 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] 210 | 211 | [[package]] 212 | name = "pluggy" 213 | version = "1.3.0" 214 | description = "plugin and hook calling mechanisms for python" 215 | optional = false 216 | python-versions = ">=3.8" 217 | groups = ["dev"] 218 | files = [ 219 | {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, 220 | {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, 221 | ] 222 | 223 | [package.extras] 224 | dev = ["pre-commit", "tox"] 225 | testing = ["pytest", "pytest-benchmark"] 226 | 227 | [[package]] 228 | name = "pre-commit" 229 | version = "3.5.0" 230 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 231 | optional = false 232 | python-versions = ">=3.8" 233 | groups = ["lint"] 234 | files = [ 235 | {file = "pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660"}, 236 | {file = "pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32"}, 237 | ] 238 | 239 | [package.dependencies] 240 | cfgv = ">=2.0.0" 241 | identify = ">=1.0.0" 242 | nodeenv = ">=0.11.1" 243 | pyyaml = ">=5.1" 244 | virtualenv = ">=20.10.0" 245 | 246 | [[package]] 247 | name = "pytest" 248 | version = "7.4.3" 249 | description = "pytest: simple powerful testing with Python" 250 | optional = false 251 | python-versions = ">=3.7" 252 | groups = ["dev"] 253 | files = [ 254 | {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, 255 | {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, 256 | ] 257 | 258 | [package.dependencies] 259 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 260 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 261 | iniconfig = "*" 262 | packaging = "*" 263 | pluggy = ">=0.12,<2.0" 264 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 265 | 266 | [package.extras] 267 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 268 | 269 | [[package]] 270 | name = "pytest-cov" 271 | version = "4.1.0" 272 | description = "Pytest plugin for measuring coverage." 273 | optional = false 274 | python-versions = ">=3.7" 275 | groups = ["dev"] 276 | files = [ 277 | {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, 278 | {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, 279 | ] 280 | 281 | [package.dependencies] 282 | coverage = {version = ">=5.2.1", extras = ["toml"]} 283 | pytest = ">=4.6" 284 | 285 | [package.extras] 286 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] 287 | 288 | [[package]] 289 | name = "pyyaml" 290 | version = "6.0.1" 291 | description = "YAML parser and emitter for Python" 292 | optional = false 293 | python-versions = ">=3.6" 294 | groups = ["lint"] 295 | files = [ 296 | {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, 297 | {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, 298 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, 299 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, 300 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, 301 | {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, 302 | {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, 303 | {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, 304 | {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, 305 | {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, 306 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, 307 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, 308 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, 309 | {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, 310 | {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, 311 | {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, 312 | {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, 313 | {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, 314 | {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, 315 | {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, 316 | {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, 317 | {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, 318 | {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, 319 | {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, 320 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, 321 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, 322 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, 323 | {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, 324 | {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, 325 | {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, 326 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, 327 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, 328 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, 329 | {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, 330 | {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, 331 | {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, 332 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, 333 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, 334 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, 335 | {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, 336 | {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, 337 | {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, 338 | {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, 339 | {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, 340 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, 341 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, 342 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, 343 | {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, 344 | {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, 345 | {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, 346 | {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, 347 | ] 348 | 349 | [[package]] 350 | name = "setuptools" 351 | version = "70.0.0" 352 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 353 | optional = false 354 | python-versions = ">=3.8" 355 | groups = ["lint"] 356 | files = [ 357 | {file = "setuptools-70.0.0-py3-none-any.whl", hash = "sha256:54faa7f2e8d2d11bcd2c07bed282eef1046b5c080d1c32add737d7b5817b1ad4"}, 358 | {file = "setuptools-70.0.0.tar.gz", hash = "sha256:f211a66637b8fa059bb28183da127d4e86396c991a942b028c6650d4319c3fd0"}, 359 | ] 360 | 361 | [package.extras] 362 | docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] 363 | testing = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov ; platform_python_implementation != \"PyPy\"", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] 364 | 365 | [[package]] 366 | name = "tomli" 367 | version = "2.0.1" 368 | description = "A lil' TOML parser" 369 | optional = false 370 | python-versions = ">=3.7" 371 | groups = ["dev"] 372 | markers = "python_full_version <= \"3.11.0a6\"" 373 | files = [ 374 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 375 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 376 | ] 377 | 378 | [[package]] 379 | name = "virtualenv" 380 | version = "20.26.6" 381 | description = "Virtual Python Environment builder" 382 | optional = false 383 | python-versions = ">=3.7" 384 | groups = ["dev", "lint"] 385 | files = [ 386 | {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"}, 387 | {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"}, 388 | ] 389 | 390 | [package.dependencies] 391 | distlib = ">=0.3.7,<1" 392 | filelock = ">=3.12.2,<4" 393 | platformdirs = ">=3.9.1,<5" 394 | 395 | [package.extras] 396 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] 397 | test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] 398 | 399 | [metadata] 400 | lock-version = "2.1" 401 | python-versions = "^3.8" 402 | content-hash = "49863014a412713abf34451e6af2f0b3e03dd2bdaf127921d60b84503146478e" 403 | --------------------------------------------------------------------------------