From 51785509c4c8e69904d9ac1225474cc84d5fbee3 Mon Sep 17 00:00:00 2001 From: Ivan Ogasawara Date: Mon, 24 Jun 2024 22:32:29 -0400 Subject: [PATCH 01/18] feat: Create graphs from yaml files --- .gitignore | 4 ++ poetry.lock | 15 ++++- pyproject.toml | 10 ++- src/umlizer/class_graph.py | 130 +++++++++++++++++++++++-------------- src/umlizer/cli.py | 10 ++- 5 files changed, 116 insertions(+), 53 deletions(-) diff --git a/.gitignore b/.gitignore index 84229f4..5810727 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,7 @@ ENV/ # mypy .mypy_cache/ + +# tmp + +tmp/ diff --git a/poetry.lock b/poetry.lock index d6d0bcd..7636f08 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "anyio" @@ -1351,6 +1351,16 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -2282,6 +2292,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -3224,4 +3235,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.8.1,<4" -content-hash = "66e582f80e082bbb3e21bd0ce8e8066387d20ce731445dca63175a173f80d90c" +content-hash = "54e39a93722ba0d1beeb444f4b6fcbe3c71a190eecf6366532ab251efdb0724b" diff --git a/pyproject.toml b/pyproject.toml index 2bbee62..ff927da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ graphviz = ">=0.20.1" atpublic = ">=4.0" typing-extensions = { version = ">=4", python = "<3.9" } typer = ">=0.9.0" +pyyaml = ">=5.4" [tool.poetry.group.dev.dependencies] pytest = ">=7.3.2" @@ -75,7 +76,7 @@ verbose = false line-length = 79 force-exclude = true src = ["./"] -ignore = ["PLR0913"] +ignore = ["PLR0913", "RUF008"] exclude = [ 'docs', ] @@ -108,3 +109,10 @@ ignore_missing_imports = true warn_unused_ignores = true warn_redundant_casts = true warn_unused_configs = true + + +[[tool.mypy.overrides]] +module = [ + "yaml", +] +ignore_missing_imports = true diff --git a/src/umlizer/class_graph.py b/src/umlizer/class_graph.py index 4358e5a..97dbd6e 100644 --- a/src/umlizer/class_graph.py +++ b/src/umlizer/class_graph.py @@ -11,12 +11,23 @@ import types from pathlib import Path -from typing import Any, Type, Union, cast +from typing import Any, Type, cast import graphviz as gv import typer +@dataclasses.dataclass +class ClassDef: + """Definition of class attributes and methods.""" + + name: str = '' + module: str = '' + bases: list[str] = [] + fields: dict[str, str] = {} + methods: dict[str, dict[str, str]] = {} + + def raise_error(message: str, exit_code: int = 1) -> None: """Raise an error using typer.""" red_text = typer.style(message, fg=typer.colors.RED, bold=True) @@ -41,7 +52,11 @@ def _get_fullname(entity: Type[Any]) -> str: return f'{entity.__module__}.{entity.__name__}' -def _get_methods(entity: Type[Any]) -> list[str]: +def _get_method_annotation(method: types.FunctionType) -> dict[str, str]: + return copy.deepcopy(getattr(method, '__annotations__', {})) + + +def _get_methods(entity: Type[Any]) -> dict[str, dict[str, str]]: """ Return a list of methods of a given entity. @@ -55,20 +70,28 @@ def _get_methods(entity: Type[Any]) -> list[str]: list A list of method names. """ - return [ - k - for k, v in entity.__dict__.items() - if not k.startswith('__') and isinstance(v, types.FunctionType) - ] + methods = {} + + for k, v in entity.__dict__.items(): + if k.startswith('__') or not isinstance(v, types.FunctionType): + continue + + methods[k] = _get_method_annotation(v) + + return methods def _get_dataclass_structure( klass: Type[Any], -) -> dict[str, Union[dict[str, str], list[str]]]: +) -> ClassDef: fields = { k: v.type.__name__ for k, v in klass.__dataclass_fields__.items() } - return {'fields': fields, 'methods': _get_methods(klass)} + return ClassDef( + name='', + fields=fields, + methods=_get_methods(klass), + ) def _get_base_classes(klass: Type[Any]) -> list[Type[Any]]: @@ -83,9 +106,9 @@ def _get_annotations(klass: Type[Any]) -> dict[str, Any]: return getattr(klass, '__annotations__', {}) -def _get_classicclass_structure( +def _get_classic_class_structure( klass: Type[Any], -) -> dict[str, Union[dict[str, str], list[str]]]: +) -> ClassDef: _methods = _get_methods(klass) fields = {} @@ -95,30 +118,39 @@ def _get_classicclass_structure( value = _get_annotations(klass).get(k, '') fields[k] = getattr(value, '__value__', str(value)) - return { - 'fields': fields, - 'methods': _methods, - } + return ClassDef( + fields=fields, + methods=_methods, + ) def _get_class_structure( klass: Type[Any], -) -> dict[str, Union[dict[str, str], list[str]]]: +) -> ClassDef: if dataclasses.is_dataclass(klass): - return _get_dataclass_structure(klass) + class_struct = _get_dataclass_structure(klass) elif inspect.isclass(klass): - return _get_classicclass_structure(klass) + class_struct = _get_classic_class_structure(klass) + else: + raise Exception('The given class is not actually a class.') - raise Exception('The given class is not actually a class.') + class_struct.module = klass.__module__ + class_struct.name = _get_fullname(klass) + class_struct.bases = [] + for ref_class in _get_base_classes(klass): + class_struct.bases.append(_get_fullname(ref_class)) -def _get_entity_class_uml(entity: Type[Any]) -> str: + return class_struct + + +def _get_entity_class_uml(klass: ClassDef) -> str: """ Generate the UML node representation for a given class entity. Parameters ---------- - entity : type + klass : type The class entity to be represented in UML. Returns @@ -127,17 +159,14 @@ def _get_entity_class_uml(entity: Type[Any]) -> str: A string representation of the class in UML node format. """ # Extract base classes, class structure, and format the class name - base_classes = ', '.join( - [_get_fullname(c) for c in _get_base_classes(entity)] - ) - class_structure = _get_class_structure(entity) - class_name = f'{entity.__name__}' + base_classes = ', '.join(klass.bases) + class_name = klass.name if base_classes: class_name += f' ({base_classes})' # Formatting fields and methods - fields_struct = cast(dict[str, str], class_structure['fields']) + fields_struct = klass.fields fields = ( '\\l'.join( [ @@ -147,16 +176,15 @@ def _get_entity_class_uml(entity: Type[Any]) -> str: ) + '\\l' ) - methods_struct = cast(list[str], class_structure['methods']) - methods = ( - '\\l'.join( - [ - f'{"-" if m.startswith("_") else "+"} {m}()' - for m in methods_struct - ] + methods_struct = cast(list[dict[str, Any]], klass.methods) + methods_raw = [] + for m in methods_struct: + m_name = m['name'] + methods_raw.append( + f'{"-" if m_name.startswith("_") else "+"} {m_name}()' ) - + '\\l' - ) + + methods = '\\l'.join(methods_raw) + '\\l' # Combine class name, fields, and methods into the UML node format uml_representation = '{' + f'{class_name}|{fields}|{methods}' + '}' @@ -258,8 +286,8 @@ def _get_classes_from_module(module_path: str) -> list[Type[Any]]: return classes_list -def create_class_diagram( - classes_list: list[Type[Any]], +def create_diagram( + classes_list: list[ClassDef], verbose: bool = False, ) -> gv.Digraph: """Create a diagram for a list of classes.""" @@ -267,24 +295,24 @@ def create_class_diagram( g.attr('node', shape='record', rankdir='BT') edges = [] - for c in classes_list: - g.node(_get_fullname(c), _get_entity_class_uml(c)) + for klass in classes_list: + g.node(klass.name, _get_entity_class_uml(klass)) - for b in _get_base_classes(c): - edges.append((_get_fullname(b), _get_fullname(c))) + for b in klass.bases: + edges.append((b, klass.name)) if verbose: - print('[II]', _get_fullname(c), '- included.') + print('[II]', klass.name, '- included.') g.edges(set(edges)) return g -def create_class_diagram_from_source( +def load_classes_definition( source: Path, verbose: bool = False -) -> gv.Digraph: +) -> list[ClassDef]: """ - Create a class diagram from the source code located at the specified path. + Load classes definition from the source code located at the specified path. Parameters ---------- @@ -295,8 +323,7 @@ def create_class_diagram_from_source( Returns ------- - gv.Digraph - Graphviz Digraph object representing the class diagram. + ClassDef Raises ------ @@ -318,4 +345,9 @@ def create_class_diagram_from_source( classes_list.extend(_get_classes_from_module(f)) else: classes_list.extend(_get_classes_from_module(path_str)) - return create_class_diagram(classes_list, verbose=verbose) + + result = [] + for c in classes_list: + result.append(_get_class_structure(c)) + + return result diff --git a/src/umlizer/cli.py b/src/umlizer/cli.py index cc06493..aea6eae 100644 --- a/src/umlizer/cli.py +++ b/src/umlizer/cli.py @@ -6,6 +6,7 @@ from pathlib import Path import typer +import yaml from typer import Context, Option from typing_extensions import Annotated @@ -83,7 +84,14 @@ def class_( source = make_absolute(source) target = make_absolute(target) / 'class_graph' - g = class_graph.create_class_diagram_from_source(source, verbose=verbose) + classes_nodes = class_graph.load_classes_definition( + source, verbose=verbose + ) + + with open(f'{target}.yaml', 'w') as f: + yaml.dump(classes_nodes, f, indent=2, sort_keys=False) + + g = class_graph.create_diagram(classes_nodes, verbose=verbose) g.format = 'png' g.render(target) From e8157784e709c7daacdb67759bb4bf1b5e0e6f59 Mon Sep 17 00:00:00 2001 From: Ivan Ogasawara Date: Mon, 24 Jun 2024 22:48:22 -0400 Subject: [PATCH 02/18] add new tests --- src/umlizer/class_graph.py | 11 +++++---- src/umlizer/cli.py | 4 +++- tests/ecommerce/__init__.py | 0 tests/ecommerce/order.py | 47 +++++++++++++++++++++++++++++++++++++ tests/ecommerce/product.py | 22 +++++++++++++++++ tests/ecommerce/user.py | 33 ++++++++++++++++++++++++++ 6 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 tests/ecommerce/__init__.py create mode 100644 tests/ecommerce/order.py create mode 100644 tests/ecommerce/product.py create mode 100644 tests/ecommerce/user.py diff --git a/src/umlizer/class_graph.py b/src/umlizer/class_graph.py index 97dbd6e..bd09158 100644 --- a/src/umlizer/class_graph.py +++ b/src/umlizer/class_graph.py @@ -23,9 +23,11 @@ class ClassDef: name: str = '' module: str = '' - bases: list[str] = [] - fields: dict[str, str] = {} - methods: dict[str, dict[str, str]] = {} + bases: list[str] = dataclasses.field(default_factory=list) + fields: dict[str, str] = dataclasses.field(default_factory=dict) + methods: dict[str, dict[str, str]] = dataclasses.field( + default_factory=dict + ) def raise_error(message: str, exit_code: int = 1) -> None: @@ -178,8 +180,7 @@ def _get_entity_class_uml(klass: ClassDef) -> str: ) methods_struct = cast(list[dict[str, Any]], klass.methods) methods_raw = [] - for m in methods_struct: - m_name = m['name'] + for m_name in methods_struct: methods_raw.append( f'{"-" if m_name.startswith("_") else "+"} {m_name}()' ) diff --git a/src/umlizer/cli.py b/src/umlizer/cli.py index aea6eae..37f49a4 100644 --- a/src/umlizer/cli.py +++ b/src/umlizer/cli.py @@ -89,7 +89,9 @@ def class_( ) with open(f'{target}.yaml', 'w') as f: - yaml.dump(classes_nodes, f, indent=2, sort_keys=False) + yaml.dump( + [c.__dict__ for c in classes_nodes], f, indent=2, sort_keys=False + ) g = class_graph.create_diagram(classes_nodes, verbose=verbose) g.format = 'png' diff --git a/tests/ecommerce/__init__.py b/tests/ecommerce/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ecommerce/order.py b/tests/ecommerce/order.py new file mode 100644 index 0000000..00df81b --- /dev/null +++ b/tests/ecommerce/order.py @@ -0,0 +1,47 @@ +from datetime import datetime +from typing import List +from user import User, Address +from product import Product + + +class Order: + """ + Represents an order in the e-commerce system. + """ + + def __init__(self, order_id: int, user: User, address: Address): + self.order_id: int = order_id + self.user: User = user + self.address: Address = address + self.products: List[Product] = [] + self.order_date: datetime = datetime.now() + self.is_shipped: bool = False + + def add_product(self, product: Product) -> None: + """ + Adds a product to the order. + """ + self.products.append(product) + + def remove_product(self, product_id: int) -> None: + """ + Removes a product from the order by its ID. + """ + self.products = [ + product + for product in self.products + if product.product_id != product_id + ] + + def ship_order(self) -> None: + """ + Marks the order as shipped. + """ + self.is_shipped = True + + def get_order_summary(self) -> str: + """ + Returns a summary of the order. + """ + product_list = ', '.join([product.name for product in self.products]) + return f'Order ID: {self.order_id}, User: {self.user.username}, Products: {product_list}, Shipped: {self.is_shipped}' diff --git a/tests/ecommerce/product.py b/tests/ecommerce/product.py new file mode 100644 index 0000000..7bc803b --- /dev/null +++ b/tests/ecommerce/product.py @@ -0,0 +1,22 @@ +class Product: + """ + Represents a product in the e-commerce system. + """ + + def __init__(self, product_id: int, name: str, price: float, stock: int): + self.product_id: int = product_id + self.name: str = name + self.price: float = price + self.stock: int = stock + + def update_stock(self, amount: int) -> None: + """ + Updates the stock quantity for the product. + """ + self.stock += amount + + def get_product_info(self) -> str: + """ + Returns the product's information. + """ + return f'Product ID: {self.product_id}, Name: {self.name}, Price: ${self.price}, Stock: {self.stock}' diff --git a/tests/ecommerce/user.py b/tests/ecommerce/user.py new file mode 100644 index 0000000..bc0cd1b --- /dev/null +++ b/tests/ecommerce/user.py @@ -0,0 +1,33 @@ +class User: + """ + Represents a user in the e-commerce system. + """ + + def __init__(self, user_id: int, username: str, email: str): + self.user_id: int = user_id + self.username: str = username + self.email: str = email + + def get_user_info(self) -> str: + """ + Returns the user's information. + """ + return f'User ID: {self.user_id}, Username: {self.username}, Email: {self.email}' + + +class Address: + """ + Represents an address in the e-commerce system. + """ + + def __init__(self, street: str, city: str, zipcode: str, user: User): + self.street: str = street + self.city: str = city + self.zipcode: str = zipcode + self.user: User = user + + def get_full_address(self) -> str: + """ + Returns the full address as a string. + """ + return f'{self.street}, {self.city}, {self.zipcode}' From 39cbd9df2fa2b9ff3fe417d8d770e16ce144c28c Mon Sep 17 00:00:00 2001 From: Ivan Ogasawara Date: Tue, 25 Jun 2024 09:34:31 -0400 Subject: [PATCH 03/18] improve the metadata file (yaml) --- src/umlizer/class_graph.py | 15 +++++++++++---- tests/ecommerce/product.py | 12 +++--------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/umlizer/class_graph.py b/src/umlizer/class_graph.py index bd09158..ea2e93d 100644 --- a/src/umlizer/class_graph.py +++ b/src/umlizer/class_graph.py @@ -51,11 +51,17 @@ def _get_fullname(entity: Type[Any]) -> str: str Fully qualified name of the entity. """ - return f'{entity.__module__}.{entity.__name__}' + if hasattr(entity, '__module__'): + return f'{entity.__module__}.{entity.__name__}' + elif hasattr(entity, '__name__'): + return entity.__name__ + + return str(entity) def _get_method_annotation(method: types.FunctionType) -> dict[str, str]: - return copy.deepcopy(getattr(method, '__annotations__', {})) + annotations = getattr(method, '__annotations__', {}) + return {k: _get_fullname(v) for k, v in annotations.items()} def _get_methods(entity: Type[Any]) -> dict[str, dict[str, str]]: @@ -105,7 +111,8 @@ def _get_base_classes(klass: Type[Any]) -> list[Type[Any]]: def _get_annotations(klass: Type[Any]) -> dict[str, Any]: - return getattr(klass, '__annotations__', {}) + annotations = getattr(klass, '__annotations__', {}) + return {k: _get_fullname(v) for k, v in annotations.items()} def _get_classic_class_structure( @@ -178,7 +185,7 @@ def _get_entity_class_uml(klass: ClassDef) -> str: ) + '\\l' ) - methods_struct = cast(list[dict[str, Any]], klass.methods) + methods_struct = cast(dict[str, dict[str, Any]], klass.methods) methods_raw = [] for m_name in methods_struct: methods_raw.append( diff --git a/tests/ecommerce/product.py b/tests/ecommerce/product.py index 7bc803b..771db37 100644 --- a/tests/ecommerce/product.py +++ b/tests/ecommerce/product.py @@ -1,7 +1,5 @@ class Product: - """ - Represents a product in the e-commerce system. - """ + """Represents a product in the e-commerce system.""" def __init__(self, product_id: int, name: str, price: float, stock: int): self.product_id: int = product_id @@ -10,13 +8,9 @@ def __init__(self, product_id: int, name: str, price: float, stock: int): self.stock: int = stock def update_stock(self, amount: int) -> None: - """ - Updates the stock quantity for the product. - """ + """Updates the stock quantity for the product.""" self.stock += amount def get_product_info(self) -> str: - """ - Returns the product's information. - """ + """Returns the product's information.""" return f'Product ID: {self.product_id}, Name: {self.name}, Price: ${self.price}, Stock: {self.stock}' From 57fa8d6e3811d71e6c5fe5a9d99132e73942216c Mon Sep 17 00:00:00 2001 From: Ivan Ogasawara Date: Tue, 25 Jun 2024 10:18:21 -0400 Subject: [PATCH 04/18] fix method for checking if an object is a function --- src/umlizer/class_graph.py | 47 ++++++++++++++++++++++++++------------ src/umlizer/utils.py | 22 ++++++++++++++++++ 2 files changed, 54 insertions(+), 15 deletions(-) create mode 100644 src/umlizer/utils.py diff --git a/src/umlizer/class_graph.py b/src/umlizer/class_graph.py index ea2e93d..199a28d 100644 --- a/src/umlizer/class_graph.py +++ b/src/umlizer/class_graph.py @@ -16,6 +16,8 @@ import graphviz as gv import typer +from umlizer.utils import is_function + @dataclasses.dataclass class ClassDef: @@ -81,7 +83,7 @@ def _get_methods(entity: Type[Any]) -> dict[str, dict[str, str]]: methods = {} for k, v in entity.__dict__.items(): - if k.startswith('__') or not isinstance(v, types.FunctionType): + if k.startswith('__') or not is_function(v): continue methods[k] = _get_method_annotation(v) @@ -119,14 +121,31 @@ def _get_classic_class_structure( klass: Type[Any], ) -> ClassDef: _methods = _get_methods(klass) + + klass_anno = _get_annotations(klass) + fields = {} for k in list(klass.__dict__.keys()): if k.startswith('__') or k in _methods: continue - value = _get_annotations(klass).get(k, '') + value = klass_anno.get(k, '') fields[k] = getattr(value, '__value__', str(value)) + if not fields: + # maybe the attributes are created in the `__init__` method. + try: + obj_tmp = klass() + obj_anno = _get_annotations(obj_tmp) + + for k in list(obj_tmp.__dict__.keys()): + if k.startswith('__') or k in _methods: + continue + value = obj_anno.get(k, '') + fields[k] = getattr(value, '__value__', str(value)) + except Exception as e: + print(e) + return ClassDef( fields=fields, methods=_methods, @@ -176,21 +195,19 @@ def _get_entity_class_uml(klass: ClassDef) -> str: # Formatting fields and methods fields_struct = klass.fields - fields = ( - '\\l'.join( - [ - f'{"-" if k.startswith("_") else "+"} {k}: {v}' - for k, v in fields_struct.items() - ] - ) - + '\\l' - ) + fields_raw = [] + for a_name, a_type in fields_struct.items(): + a_visibility = '-' if a_name.startswith('_') else '+' + fields_raw.append(f'{a_visibility} {a_name}: {a_type}') + + fields = '\\l'.join(fields_raw) + '\\l' + methods_struct = cast(dict[str, dict[str, Any]], klass.methods) methods_raw = [] - for m_name in methods_struct: - methods_raw.append( - f'{"-" if m_name.startswith("_") else "+"} {m_name}()' - ) + for m_name, m_metadata in methods_struct.items(): + m_visibility = '-' if m_name.startswith('_') else '+' + m_type = m_metadata.get('return', 'Any') + methods_raw.append(f'{m_visibility} {m_name}(): {m_type}') methods = '\\l'.join(methods_raw) + '\\l' diff --git a/src/umlizer/utils.py b/src/umlizer/utils.py new file mode 100644 index 0000000..066ff20 --- /dev/null +++ b/src/umlizer/utils.py @@ -0,0 +1,22 @@ +"""A set of utilitary tools.""" +import inspect + +from typing import Any + + +def is_function(obj: Any) -> bool: + """ + Check if the given object is a function, method, or built-in method. + + Parameters + ---------- + obj : Any + The object to check. + + Returns + ------- + bool + True if the object is a function, method, or built-in method, + False otherwise. + """ + return inspect.isroutine(obj) From 71055193bafb1d43c4bdf55a71f4df5a154d0e75 Mon Sep 17 00:00:00 2001 From: Ivan Ogasawara Date: Tue, 25 Jun 2024 10:37:48 -0400 Subject: [PATCH 05/18] fix issues from classes with __init__ --- src/umlizer/class_graph.py | 61 +++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/src/umlizer/class_graph.py b/src/umlizer/class_graph.py index 199a28d..31cace9 100644 --- a/src/umlizer/class_graph.py +++ b/src/umlizer/class_graph.py @@ -1,6 +1,7 @@ """Create graphviz for classes.""" from __future__ import annotations +import ast import copy import dataclasses import glob @@ -8,6 +9,7 @@ import inspect import os import sys +import textwrap import types from pathlib import Path @@ -117,13 +119,48 @@ def _get_annotations(klass: Type[Any]) -> dict[str, Any]: return {k: _get_fullname(v) for k, v in annotations.items()} -def _get_classic_class_structure( - klass: Type[Any], -) -> ClassDef: - _methods = _get_methods(klass) +def _get_init_attributes(klass: Type[Any]) -> dict[str, str]: + """Extract attributes declared in the __init__ method using `self`.""" + attributes: dict[str, str] = {} + init_method = klass.__dict__.get('__init__') - klass_anno = _get_annotations(klass) + if not init_method or not isinstance(init_method, types.FunctionType): + return attributes + + source_lines, _ = inspect.getsourcelines(init_method) + source_code = textwrap.dedent(''.join(source_lines)) + tree = ast.parse(source_code) + + for node in ast.walk(tree): + if isinstance(node, ast.AnnAssign): + target = node.target + if ( + isinstance(target, ast.Attribute) + and isinstance(target.value, ast.Name) + and target.value.id == 'self' + ): + attr_name = target.attr + attr_type = 'Any' # Default type if not explicitly typed + # Try to get the type from the annotation if it exists + if isinstance(node.value, ast.Name): + attr_type = node.annotation.id # type: ignore[attr-defined] + elif isinstance(node.value, ast.Call) and isinstance( + node.value.func, ast.Name + ): + attr_type = node.value.func.annotation.id # type: ignore[attr-defined] + elif isinstance(node.value, ast.Constant): + attr_type = type(node.value.value).__name__ + + attributes[attr_name] = attr_type + + return attributes + + +def _get_classic_class_structure(klass: Type[Any]) -> ClassDef: + """Get the structure of a classic (non-dataclass) class.""" + _methods = _get_methods(klass) + klass_anno = _get_annotations(klass) fields = {} for k in list(klass.__dict__.keys()): @@ -133,18 +170,8 @@ def _get_classic_class_structure( fields[k] = getattr(value, '__value__', str(value)) if not fields: - # maybe the attributes are created in the `__init__` method. - try: - obj_tmp = klass() - obj_anno = _get_annotations(obj_tmp) - - for k in list(obj_tmp.__dict__.keys()): - if k.startswith('__') or k in _methods: - continue - value = obj_anno.get(k, '') - fields[k] = getattr(value, '__value__', str(value)) - except Exception as e: - print(e) + # Extract attributes from the `__init__` method if defined there. + fields = _get_init_attributes(klass) return ClassDef( fields=fields, From 0b7db00484b14059c62378a029fe21b2b09f482c Mon Sep 17 00:00:00 2001 From: Ivan Ogasawara Date: Tue, 25 Jun 2024 10:55:51 -0400 Subject: [PATCH 06/18] add unknown type for builtin attributes --- src/umlizer/class_graph.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/umlizer/class_graph.py b/src/umlizer/class_graph.py index 31cace9..8fc676e 100644 --- a/src/umlizer/class_graph.py +++ b/src/umlizer/class_graph.py @@ -166,7 +166,7 @@ def _get_classic_class_structure(klass: Type[Any]) -> ClassDef: for k in list(klass.__dict__.keys()): if k.startswith('__') or k in _methods: continue - value = klass_anno.get(k, '') + value = klass_anno.get(k, 'UNKNOWN') fields[k] = getattr(value, '__value__', str(value)) if not fields: @@ -334,6 +334,7 @@ def _get_classes_from_module(module_path: str) -> list[Type[Any]]: print(f' Error loading module {module_name} '.center(80, '=')) print(e) print('.' * 80) + sys.path = original_path return [] return classes_list @@ -385,6 +386,7 @@ def load_classes_definition( If the provided path is not a directory. """ classes_list = [] + module_files = [] path_str = str(source) @@ -392,14 +394,11 @@ def load_classes_definition( raise_error(f'Path "{path_str}" doesn\'t exist.', 1) if os.path.isdir(path_str): sys.path.insert(0, path_str) - - for f in _search_modules(path_str): - classes_list.extend(_get_classes_from_module(f)) + module_files.extend(_search_modules(path_str)) else: - classes_list.extend(_get_classes_from_module(path_str)) + module_files.append(path_str) - result = [] - for c in classes_list: - result.append(_get_class_structure(c)) + for file_path in module_files: + classes_list.extend(_get_classes_from_module(file_path)) - return result + return [_get_class_structure(cls) for cls in classes_list] From 6c5aa016c63a2176aad725e53847b873e17948d8 Mon Sep 17 00:00:00 2001 From: Ivan Ogasawara Date: Tue, 25 Jun 2024 11:29:44 -0400 Subject: [PATCH 07/18] add parameters with annotation to the methods --- src/umlizer/class_graph.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/umlizer/class_graph.py b/src/umlizer/class_graph.py index 8fc676e..95cc5f7 100644 --- a/src/umlizer/class_graph.py +++ b/src/umlizer/class_graph.py @@ -233,8 +233,14 @@ def _get_entity_class_uml(klass: ClassDef) -> str: methods_raw = [] for m_name, m_metadata in methods_struct.items(): m_visibility = '-' if m_name.startswith('_') else '+' - m_type = m_metadata.get('return', 'Any') - methods_raw.append(f'{m_visibility} {m_name}(): {m_type}') + m_type = m_metadata.get('return', 'Any').replace('builtins.', '') + m_params_raw = [ + f"{k}: {v.replace('builtins.', '')}" + for k, v in m_metadata.items() + if k != 'return' + ] + m_params = ', '.join(m_params_raw) + methods_raw.append(f'{m_visibility} {m_name}({m_params}): {m_type}') methods = '\\l'.join(methods_raw) + '\\l' From 243fb5256d95e505943a520ab918d45a12acd5aa Mon Sep 17 00:00:00 2001 From: Ivan Ogasawara Date: Tue, 25 Jun 2024 11:53:15 -0400 Subject: [PATCH 08/18] add dot2svg function to the pipeline --- docs/changelog.md | 10 +++++----- pyproject.toml | 1 + src/umlizer/class_graph.py | 3 ++- src/umlizer/cli.py | 26 ++++++++++++++++++++++++++ 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 04bcbae..f30a28c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,15 +1,15 @@ # Release Notes + --- ## [0.1.1](https://github.com/osl-incubator/umlizer/compare/0.1.0...0.1.1) (2024-01-18) - ### Bug Fixes -* Fix initial code ([#8](https://github.com/osl-incubator/umlizer/issues/8)) ([de02fb0](https://github.com/osl-incubator/umlizer/commit/de02fb0df74e1c1b6ccf1302154ef6145c068730)) -* Fix Release Job on CI ([#9](https://github.com/osl-incubator/umlizer/issues/9)) ([70ec6ce](https://github.com/osl-incubator/umlizer/commit/70ec6ce2072366ca624a965249f6f95e1263578d)) -* Fix replace configuration in the Release workflow ([#10](https://github.com/osl-incubator/umlizer/issues/10)) ([367b83e](https://github.com/osl-incubator/umlizer/commit/367b83e34e30b835e5f09540e97ad937587488ec)) -* Fix replace configuration in the Release workflow ([#11](https://github.com/osl-incubator/umlizer/issues/11)) ([dec6d29](https://github.com/osl-incubator/umlizer/commit/dec6d2927eddbed173df369c7abb2f3c4c390e71)) +- Fix initial code ([#8](https://github.com/osl-incubator/umlizer/issues/8)) ([de02fb0](https://github.com/osl-incubator/umlizer/commit/de02fb0df74e1c1b6ccf1302154ef6145c068730)) +- Fix Release Job on CI ([#9](https://github.com/osl-incubator/umlizer/issues/9)) ([70ec6ce](https://github.com/osl-incubator/umlizer/commit/70ec6ce2072366ca624a965249f6f95e1263578d)) +- Fix replace configuration in the Release workflow ([#10](https://github.com/osl-incubator/umlizer/issues/10)) ([367b83e](https://github.com/osl-incubator/umlizer/commit/367b83e34e30b835e5f09540e97ad937587488ec)) +- Fix replace configuration in the Release workflow ([#11](https://github.com/osl-incubator/umlizer/issues/11)) ([dec6d29](https://github.com/osl-incubator/umlizer/commit/dec6d2927eddbed173df369c7abb2f3c4c390e71)) # Release Notes diff --git a/pyproject.toml b/pyproject.toml index ff927da..0d530b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ testpaths = [ [tool.bandit] exclude_dirs = ["tests"] targets = "./" +skips = ["B602"] [tool.vulture] exclude = ["tests"] diff --git a/src/umlizer/class_graph.py b/src/umlizer/class_graph.py index 95cc5f7..238e055 100644 --- a/src/umlizer/class_graph.py +++ b/src/umlizer/class_graph.py @@ -97,7 +97,8 @@ def _get_dataclass_structure( klass: Type[Any], ) -> ClassDef: fields = { - k: v.type.__name__ for k, v in klass.__dataclass_fields__.items() + k: getattr(v.type, '__name__', 'Any') + for k, v in klass.__dataclass_fields__.items() } return ClassDef( name='', diff --git a/src/umlizer/cli.py b/src/umlizer/cli.py index 37f49a4..a2e22e0 100644 --- a/src/umlizer/cli.py +++ b/src/umlizer/cli.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +import subprocess from pathlib import Path @@ -16,6 +17,29 @@ app = typer.Typer() +def dot2svg(target: Path) -> None: + """ + Run the `dot` command to convert a Graphviz file to SVG format. + + Parameters + ---------- + target : str + The target Graphviz file to be converted. + """ + command = f'dot -Tsvg {target} -o {target}.svg' + try: + result = subprocess.run( + command, + shell=True, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + print(result.stdout.decode()) + except subprocess.CalledProcessError as e: + print(f'Error occurred: {e.stderr.decode()}') + + def make_absolute(relative_path: Path) -> Path: """ Convert a relative Path to absolute, relative to the current cwd. @@ -97,6 +121,8 @@ def class_( g.format = 'png' g.render(target) + dot2svg(target) + if __name__ == '__main__': app() From 31637069a6cf27e923f38bc958ee8d32797bea07 Mon Sep 17 00:00:00 2001 From: Ivan Ogasawara Date: Tue, 25 Jun 2024 12:13:35 -0400 Subject: [PATCH 09/18] fix issues with the class name --- src/umlizer/class_graph.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/umlizer/class_graph.py b/src/umlizer/class_graph.py index 238e055..f01c0e1 100644 --- a/src/umlizer/class_graph.py +++ b/src/umlizer/class_graph.py @@ -219,7 +219,10 @@ def _get_entity_class_uml(klass: ClassDef) -> str: class_name = klass.name if base_classes: - class_name += f' ({base_classes})' + if len(base_classes) < 20: # noqa: PLR2004 + class_name += f' ({base_classes})' + else: + class_name += ' (\\n' + base_classes.replace(', ', ',\\n ') + ')' # Formatting fields and methods fields_struct = klass.fields From 3ce60af56caa7138429c3da6be582aaf728de424 Mon Sep 17 00:00:00 2001 From: Ivan Ogasawara Date: Tue, 25 Jun 2024 19:10:15 -0400 Subject: [PATCH 10/18] add flag for exclude modules --- src/umlizer/class_graph.py | 14 +++++++++++--- src/umlizer/cli.py | 11 ++++++++++- src/umlizer/utils.py | 25 +++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/umlizer/class_graph.py b/src/umlizer/class_graph.py index f01c0e1..1ecef80 100644 --- a/src/umlizer/class_graph.py +++ b/src/umlizer/class_graph.py @@ -254,7 +254,8 @@ def _get_entity_class_uml(klass: ClassDef) -> str: def _search_modules( - target: str, exclude_pattern: list[str] = ['__pycache__'] + target: str, + exclude_pattern: list[str] = ['__pycache__'], ) -> list[str]: """ Search for Python modules in a given path, excluding specified patterns. @@ -372,7 +373,9 @@ def create_diagram( def load_classes_definition( - source: Path, verbose: bool = False + source: Path, + exclude: str, + verbose: bool = False, ) -> list[ClassDef]: """ Load classes definition from the source code located at the specified path. @@ -381,6 +384,7 @@ def load_classes_definition( ---------- source : Path The path to the source code. + exclude: pattern that excludes directories, modules or classes verbose : bool, optional Flag to enable verbose logging, by default False. @@ -404,7 +408,11 @@ def load_classes_definition( raise_error(f'Path "{path_str}" doesn\'t exist.', 1) if os.path.isdir(path_str): sys.path.insert(0, path_str) - module_files.extend(_search_modules(path_str)) + exclude_pattern = [exclude.strip() for exclude in exclude.split(',')] + exclude_pattern.append('__pycache__') + module_files.extend( + _search_modules(path_str, exclude_pattern=exclude_pattern) + ) else: module_files.append(path_str) diff --git a/src/umlizer/cli.py b/src/umlizer/cli.py index a2e22e0..8e3c72d 100644 --- a/src/umlizer/cli.py +++ b/src/umlizer/cli.py @@ -100,6 +100,15 @@ def class_( ..., help='Target path where the UML graph will be generated.' ), ] = Path('/tmp/'), + exclude: Annotated[ + str, + typer.Option( + help=( + 'Exclude directories, modules, or classes ' + '(eg. "migrations/*,scripts/*").' + ) + ), + ] = '', verbose: Annotated[ bool, typer.Option(help='Active the verbose mode.') ] = False, @@ -109,7 +118,7 @@ def class_( target = make_absolute(target) / 'class_graph' classes_nodes = class_graph.load_classes_definition( - source, verbose=verbose + source, exclude=exclude, verbose=verbose ) with open(f'{target}.yaml', 'w') as f: diff --git a/src/umlizer/utils.py b/src/umlizer/utils.py index 066ff20..559b187 100644 --- a/src/umlizer/utils.py +++ b/src/umlizer/utils.py @@ -1,9 +1,34 @@ """A set of utilitary tools.""" import inspect +import re from typing import Any +def blob_to_regex(blob: str) -> str: + """ + Convert a blob pattern to a regular expression. + + Parameters + ---------- + blob : str + The blob pattern to convert. + + Returns + ------- + str + The equivalent regular expression. + """ + # Escape special characters except for * and ? + blob = re.escape(blob) + + # Replace the escaped * and ? with their regex equivalents + blob = blob.replace(r'\*', '.*').replace(r'\?', '.') + + # Add start and end line anchors to the pattern + return '^' + blob + '$' + + def is_function(obj: Any) -> bool: """ Check if the given object is a function, method, or built-in method. From 62dccc772e8aa95d6131c2c3d2035e16c0d3325f Mon Sep 17 00:00:00 2001 From: Ivan Ogasawara Date: Tue, 25 Jun 2024 19:58:00 -0400 Subject: [PATCH 11/18] fix _get_classes_from_module --- src/umlizer/class_graph.py | 42 ++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/src/umlizer/class_graph.py b/src/umlizer/class_graph.py index 1ecef80..b7a9340 100644 --- a/src/umlizer/class_graph.py +++ b/src/umlizer/class_graph.py @@ -329,25 +329,40 @@ def _get_classes_from_module(module_path: str) -> list[Type[Any]]: """ module_path, module_name = _extract_module_name(module_path) original_path = copy.deepcopy(sys.path) + + sys.path.insert(0, module_path) try: - sys.path.insert(0, module_path) module = importlib.import_module(module_name) - sys.path = original_path - classes_list = [ - getattr(module, o) - for o in dir(module) - if inspect.isclass(getattr(module, o)) and not o.startswith('__') - ] - return classes_list except KeyboardInterrupt: raise_error('KeyboardInterrupt', 1) except Exception as e: - print(f' Error loading module {module_name} '.center(80, '=')) + short_module_path = '.'.join(module_path.split('/')[-3:]) + print(f' Error loading module {short_module_path} '.center(80, '=')) print(e) print('.' * 80) sys.path = original_path return [] - return classes_list + + # If __all__ is defined, get only the classes listed in __all__ + all_classes_exported = [] + if hasattr(module, '__all__'): + for name in module.__all__: + if not inspect.isclass(getattr(module, name)): + continue + all_classes_exported.append(getattr(module, name)) + + # Get all classes defined directly in the module + all_classes = [] + for name in dir(module): + if not ( + inspect.isclass(getattr(module, name)) + and getattr(getattr(module, name), '__module__', None) + == module.__name__ + ): + continue + all_classes.append(getattr(module, name)) + sys.path = original_path + return all_classes + all_classes_exported def create_diagram( @@ -408,7 +423,12 @@ def load_classes_definition( raise_error(f'Path "{path_str}" doesn\'t exist.', 1) if os.path.isdir(path_str): sys.path.insert(0, path_str) - exclude_pattern = [exclude.strip() for exclude in exclude.split(',')] + if exclude: + exclude_pattern = [ + exclude.strip() for exclude in exclude.split(',') + ] + else: + exclude_pattern = [] exclude_pattern.append('__pycache__') module_files.extend( _search_modules(path_str, exclude_pattern=exclude_pattern) From 9df3c54ecf01505323421499d7e8aecb14bdd145 Mon Sep 17 00:00:00 2001 From: Ivan Ogasawara Date: Tue, 25 Jun 2024 21:50:23 -0400 Subject: [PATCH 12/18] fix issues with rstrip --- src/umlizer/class_graph.py | 6 +++++- src/umlizer/cli.py | 11 +++++++++++ src/umlizer/plugins/__init__.py | 1 + src/umlizer/plugins/django.py | 18 ++++++++++++++++++ 4 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 src/umlizer/plugins/__init__.py create mode 100644 src/umlizer/plugins/django.py diff --git a/src/umlizer/class_graph.py b/src/umlizer/class_graph.py index b7a9340..4b54eb8 100644 --- a/src/umlizer/class_graph.py +++ b/src/umlizer/class_graph.py @@ -309,7 +309,11 @@ def _extract_module_name(module_path: str) -> tuple[str, str]: module_split = module_path.split(os.sep) module_path = os.sep.join(module_split[:-1]) module_filename = module_split[-1] - module_name = module_filename.rstrip('.py') + + if module_filename.endswith('.py'): + module_name = module_filename[:-3] + else: + module_name = module_filename return module_path, module_name diff --git a/src/umlizer/cli.py b/src/umlizer/cli.py index 8e3c72d..0f4b375 100644 --- a/src/umlizer/cli.py +++ b/src/umlizer/cli.py @@ -109,6 +109,12 @@ def class_( ) ), ] = '', + django_settings: Annotated[ + str, + typer.Option( + help='Django settings module (eg. "config.settings.dev").' + ), + ] = '', verbose: Annotated[ bool, typer.Option(help='Active the verbose mode.') ] = False, @@ -117,6 +123,11 @@ def class_( source = make_absolute(source) target = make_absolute(target) / 'class_graph' + if django_settings: + from umlizer.plugins import django + + django.setup(django_settings) + classes_nodes = class_graph.load_classes_definition( source, exclude=exclude, verbose=verbose ) diff --git a/src/umlizer/plugins/__init__.py b/src/umlizer/plugins/__init__.py new file mode 100644 index 0000000..44d7d43 --- /dev/null +++ b/src/umlizer/plugins/__init__.py @@ -0,0 +1 @@ +"""Set of functions for integrating to another libraries.""" diff --git a/src/umlizer/plugins/django.py b/src/umlizer/plugins/django.py new file mode 100644 index 0000000..a1140f6 --- /dev/null +++ b/src/umlizer/plugins/django.py @@ -0,0 +1,18 @@ +"""Set of functions for integrating to django.""" + +import os + + +def setup(settings_module: str) -> None: + """ + Set up the Django environment. + + Parameters + ---------- + settings_module : str + The Django settings module to use. + """ + import django + + os.environ['DJANGO_SETTINGS_MODULE'] = settings_module + django.setup() From d0cba79a55e5469efa7815cacd402127dbdc2f6d Mon Sep 17 00:00:00 2001 From: Ivan Ogasawara Date: Wed, 26 Jun 2024 12:43:39 -0400 Subject: [PATCH 13/18] split into class graph and inspector --- src/umlizer/class_graph.py | 367 +------------------------------------ src/umlizer/cli.py | 4 +- src/umlizer/inspector.py | 361 ++++++++++++++++++++++++++++++++++++ src/umlizer/utils.py | 9 + 4 files changed, 374 insertions(+), 367 deletions(-) create mode 100644 src/umlizer/inspector.py diff --git a/src/umlizer/class_graph.py b/src/umlizer/class_graph.py index 4b54eb8..c4736ea 100644 --- a/src/umlizer/class_graph.py +++ b/src/umlizer/class_graph.py @@ -1,203 +1,11 @@ """Create graphviz for classes.""" from __future__ import annotations -import ast -import copy -import dataclasses -import glob -import importlib.util -import inspect -import os -import sys -import textwrap -import types - -from pathlib import Path -from typing import Any, Type, cast +from typing import Any, cast import graphviz as gv -import typer - -from umlizer.utils import is_function - - -@dataclasses.dataclass -class ClassDef: - """Definition of class attributes and methods.""" - - name: str = '' - module: str = '' - bases: list[str] = dataclasses.field(default_factory=list) - fields: dict[str, str] = dataclasses.field(default_factory=dict) - methods: dict[str, dict[str, str]] = dataclasses.field( - default_factory=dict - ) - - -def raise_error(message: str, exit_code: int = 1) -> None: - """Raise an error using typer.""" - red_text = typer.style(message, fg=typer.colors.RED, bold=True) - typer.echo(red_text, err=True, color=True) - raise typer.Exit(exit_code) - - -def _get_fullname(entity: Type[Any]) -> str: - """ - Get the fully qualified name of a given entity. - - Parameters - ---------- - entity : types.ModuleType - The entity for which the full name is required. - - Returns - ------- - str - Fully qualified name of the entity. - """ - if hasattr(entity, '__module__'): - return f'{entity.__module__}.{entity.__name__}' - elif hasattr(entity, '__name__'): - return entity.__name__ - - return str(entity) - - -def _get_method_annotation(method: types.FunctionType) -> dict[str, str]: - annotations = getattr(method, '__annotations__', {}) - return {k: _get_fullname(v) for k, v in annotations.items()} - - -def _get_methods(entity: Type[Any]) -> dict[str, dict[str, str]]: - """ - Return a list of methods of a given entity. - - Parameters - ---------- - entity : types.ModuleType - The entity whose methods are to be extracted. - - Returns - ------- - list - A list of method names. - """ - methods = {} - - for k, v in entity.__dict__.items(): - if k.startswith('__') or not is_function(v): - continue - - methods[k] = _get_method_annotation(v) - - return methods - - -def _get_dataclass_structure( - klass: Type[Any], -) -> ClassDef: - fields = { - k: getattr(v.type, '__name__', 'Any') - for k, v in klass.__dataclass_fields__.items() - } - return ClassDef( - name='', - fields=fields, - methods=_get_methods(klass), - ) - - -def _get_base_classes(klass: Type[Any]) -> list[Type[Any]]: - return [ - c - for c in klass.__mro__ - if c.__name__ not in ('object', klass.__name__) - ] - - -def _get_annotations(klass: Type[Any]) -> dict[str, Any]: - annotations = getattr(klass, '__annotations__', {}) - return {k: _get_fullname(v) for k, v in annotations.items()} - - -def _get_init_attributes(klass: Type[Any]) -> dict[str, str]: - """Extract attributes declared in the __init__ method using `self`.""" - attributes: dict[str, str] = {} - init_method = klass.__dict__.get('__init__') - - if not init_method or not isinstance(init_method, types.FunctionType): - return attributes - - source_lines, _ = inspect.getsourcelines(init_method) - source_code = textwrap.dedent(''.join(source_lines)) - tree = ast.parse(source_code) - - for node in ast.walk(tree): - if isinstance(node, ast.AnnAssign): - target = node.target - if ( - isinstance(target, ast.Attribute) - and isinstance(target.value, ast.Name) - and target.value.id == 'self' - ): - attr_name = target.attr - attr_type = 'Any' # Default type if not explicitly typed - - # Try to get the type from the annotation if it exists - if isinstance(node.value, ast.Name): - attr_type = node.annotation.id # type: ignore[attr-defined] - elif isinstance(node.value, ast.Call) and isinstance( - node.value.func, ast.Name - ): - attr_type = node.value.func.annotation.id # type: ignore[attr-defined] - elif isinstance(node.value, ast.Constant): - attr_type = type(node.value.value).__name__ - - attributes[attr_name] = attr_type - - return attributes - - -def _get_classic_class_structure(klass: Type[Any]) -> ClassDef: - """Get the structure of a classic (non-dataclass) class.""" - _methods = _get_methods(klass) - klass_anno = _get_annotations(klass) - fields = {} - - for k in list(klass.__dict__.keys()): - if k.startswith('__') or k in _methods: - continue - value = klass_anno.get(k, 'UNKNOWN') - fields[k] = getattr(value, '__value__', str(value)) - - if not fields: - # Extract attributes from the `__init__` method if defined there. - fields = _get_init_attributes(klass) - - return ClassDef( - fields=fields, - methods=_methods, - ) - - -def _get_class_structure( - klass: Type[Any], -) -> ClassDef: - if dataclasses.is_dataclass(klass): - class_struct = _get_dataclass_structure(klass) - elif inspect.isclass(klass): - class_struct = _get_classic_class_structure(klass) - else: - raise Exception('The given class is not actually a class.') - - class_struct.module = klass.__module__ - class_struct.name = _get_fullname(klass) - class_struct.bases = [] - for ref_class in _get_base_classes(klass): - class_struct.bases.append(_get_fullname(ref_class)) - - return class_struct +from umlizer.inspector import ClassDef def _get_entity_class_uml(klass: ClassDef) -> str: @@ -253,122 +61,6 @@ def _get_entity_class_uml(klass: ClassDef) -> str: return uml_representation -def _search_modules( - target: str, - exclude_pattern: list[str] = ['__pycache__'], -) -> list[str]: - """ - Search for Python modules in a given path, excluding specified patterns. - - Parameters - ---------- - target : str - Target directory to search for modules. - exclude_pattern : list, optional - Patterns to exclude from the search, by default ['__pycache__']. - - Returns - ------- - list - A list of module file paths. - """ - results = [] - for f in glob.glob('{}/**/*'.format(target), recursive=True): - skip = False - for x in exclude_pattern: - if x in f: - skip = True - break - if not skip and f.endswith('.py'): - results.append(f) - - return results - - -def _extract_filename(filename: str) -> str: - return filename.split(os.sep)[-1].split('.')[0] - - -def _extract_module_name(module_path: str) -> tuple[str, str]: - """ - Extract the module name from its file path. - - Parameters - ---------- - module_path : str - The file path of the module. - - Returns - ------- - tuple[str, str] - Returns the module path and the module name. - """ - # Extract the module name from the path. - # This needs to be adapted depending on your project's structure. - # Example: 'path/to/module.py' -> 'path.to.module' - module_split = module_path.split(os.sep) - module_path = os.sep.join(module_split[:-1]) - module_filename = module_split[-1] - - if module_filename.endswith('.py'): - module_name = module_filename[:-3] - else: - module_name = module_filename - return module_path, module_name - - -def _get_classes_from_module(module_path: str) -> list[Type[Any]]: - """ - Extract classes from a given module path using importlib.import_module. - - Parameters - ---------- - module_path : str - The path to the module from which classes are to be extracted. - - Returns - ------- - list - A list of class objects. - """ - module_path, module_name = _extract_module_name(module_path) - original_path = copy.deepcopy(sys.path) - - sys.path.insert(0, module_path) - try: - module = importlib.import_module(module_name) - except KeyboardInterrupt: - raise_error('KeyboardInterrupt', 1) - except Exception as e: - short_module_path = '.'.join(module_path.split('/')[-3:]) - print(f' Error loading module {short_module_path} '.center(80, '=')) - print(e) - print('.' * 80) - sys.path = original_path - return [] - - # If __all__ is defined, get only the classes listed in __all__ - all_classes_exported = [] - if hasattr(module, '__all__'): - for name in module.__all__: - if not inspect.isclass(getattr(module, name)): - continue - all_classes_exported.append(getattr(module, name)) - - # Get all classes defined directly in the module - all_classes = [] - for name in dir(module): - if not ( - inspect.isclass(getattr(module, name)) - and getattr(getattr(module, name), '__module__', None) - == module.__name__ - ): - continue - all_classes.append(getattr(module, name)) - sys.path = original_path - return all_classes + all_classes_exported - - def create_diagram( classes_list: list[ClassDef], verbose: bool = False, @@ -389,58 +81,3 @@ def create_diagram( g.edges(set(edges)) return g - - -def load_classes_definition( - source: Path, - exclude: str, - verbose: bool = False, -) -> list[ClassDef]: - """ - Load classes definition from the source code located at the specified path. - - Parameters - ---------- - source : Path - The path to the source code. - exclude: pattern that excludes directories, modules or classes - verbose : bool, optional - Flag to enable verbose logging, by default False. - - Returns - ------- - ClassDef - - Raises - ------ - FileNotFoundError - If the provided path does not exist. - ValueError - If the provided path is not a directory. - """ - classes_list = [] - module_files = [] - - path_str = str(source) - - if not os.path.exists(path_str): - raise_error(f'Path "{path_str}" doesn\'t exist.', 1) - if os.path.isdir(path_str): - sys.path.insert(0, path_str) - if exclude: - exclude_pattern = [ - exclude.strip() for exclude in exclude.split(',') - ] - else: - exclude_pattern = [] - exclude_pattern.append('__pycache__') - module_files.extend( - _search_modules(path_str, exclude_pattern=exclude_pattern) - ) - else: - module_files.append(path_str) - - for file_path in module_files: - classes_list.extend(_get_classes_from_module(file_path)) - - return [_get_class_structure(cls) for cls in classes_list] diff --git a/src/umlizer/cli.py b/src/umlizer/cli.py index 0f4b375..790abf6 100644 --- a/src/umlizer/cli.py +++ b/src/umlizer/cli.py @@ -12,7 +12,7 @@ from typer import Context, Option from typing_extensions import Annotated -from umlizer import __version__, class_graph +from umlizer import __version__, class_graph, inspector app = typer.Typer() @@ -128,7 +128,7 @@ def class_( django.setup(django_settings) - classes_nodes = class_graph.load_classes_definition( + classes_nodes = inspector.load_classes_definition( source, exclude=exclude, verbose=verbose ) diff --git a/src/umlizer/inspector.py b/src/umlizer/inspector.py new file mode 100644 index 0000000..8bda589 --- /dev/null +++ b/src/umlizer/inspector.py @@ -0,0 +1,361 @@ +"""Create graphviz for classes.""" +from __future__ import annotations + +import ast +import copy +import dataclasses +import glob +import importlib.util +import inspect +import os +import sys +import textwrap +import types + +from pathlib import Path +from typing import Any, Type + +from umlizer.utils import is_function, raise_error + + +@dataclasses.dataclass +class ClassDef: + """Definition of class attributes and methods.""" + + name: str = '' + module: str = '' + bases: list[str] = dataclasses.field(default_factory=list) + fields: dict[str, str] = dataclasses.field(default_factory=dict) + methods: dict[str, dict[str, str]] = dataclasses.field( + default_factory=dict + ) + + +def _get_fullname(entity: Type[Any]) -> str: + """ + Get the fully qualified name of a given entity. + + Parameters + ---------- + entity : types.ModuleType + The entity for which the full name is required. + + Returns + ------- + str + Fully qualified name of the entity. + """ + if hasattr(entity, '__module__'): + return f'{entity.__module__}.{entity.__name__}' + elif hasattr(entity, '__name__'): + return entity.__name__ + + return str(entity) + + +def _get_method_annotation(method: types.FunctionType) -> dict[str, str]: + annotations = getattr(method, '__annotations__', {}) + return {k: _get_fullname(v) for k, v in annotations.items()} + + +def _get_methods(entity: Type[Any]) -> dict[str, dict[str, str]]: + """ + Return a list of methods of a given entity. + + Parameters + ---------- + entity : types.ModuleType + The entity whose methods are to be extracted. + + Returns + ------- + list + A list of method names. + """ + methods = {} + + for k, v in entity.__dict__.items(): + if k.startswith('__') or not is_function(v): + continue + + methods[k] = _get_method_annotation(v) + + return methods + + +def _get_dataclass_structure( + klass: Type[Any], +) -> ClassDef: + fields = { + k: getattr(v.type, '__name__', 'Any') + for k, v in klass.__dataclass_fields__.items() + } + return ClassDef( + name='', + fields=fields, + methods=_get_methods(klass), + ) + + +def _get_base_classes(klass: Type[Any]) -> list[Type[Any]]: + return [ + c + for c in klass.__mro__ + if c.__name__ not in ('object', klass.__name__) + ] + + +def _get_annotations(klass: Type[Any]) -> dict[str, Any]: + annotations = getattr(klass, '__annotations__', {}) + return {k: _get_fullname(v) for k, v in annotations.items()} + + +def _get_init_attributes(klass: Type[Any]) -> dict[str, str]: + """Extract attributes declared in the __init__ method using `self`.""" + attributes: dict[str, str] = {} + init_method = klass.__dict__.get('__init__') + + if not init_method or not isinstance(init_method, types.FunctionType): + return attributes + + source_lines, _ = inspect.getsourcelines(init_method) + source_code = textwrap.dedent(''.join(source_lines)) + tree = ast.parse(source_code) + + for node in ast.walk(tree): + if isinstance(node, ast.AnnAssign): + target = node.target + if ( + isinstance(target, ast.Attribute) + and isinstance(target.value, ast.Name) + and target.value.id == 'self' + ): + attr_name = target.attr + attr_type = 'Any' # Default type if not explicitly typed + + # Try to get the type from the annotation if it exists + if isinstance(node.value, ast.Name): + attr_type = node.annotation.id # type: ignore[attr-defined] + elif isinstance(node.value, ast.Call) and isinstance( + node.value.func, ast.Name + ): + attr_type = node.value.func.annotation.id # type: ignore[attr-defined] + elif isinstance(node.value, ast.Constant): + attr_type = type(node.value.value).__name__ + + attributes[attr_name] = attr_type + + return attributes + + +def _get_classic_class_structure(klass: Type[Any]) -> ClassDef: + """Get the structure of a classic (non-dataclass) class.""" + _methods = _get_methods(klass) + klass_anno = _get_annotations(klass) + fields = {} + + for k in list(klass.__dict__.keys()): + if k.startswith('__') or k in _methods: + continue + value = klass_anno.get(k, 'UNKNOWN') + fields[k] = getattr(value, '__value__', str(value)) + + if not fields: + # Extract attributes from the `__init__` method if defined there. + fields = _get_init_attributes(klass) + + return ClassDef( + fields=fields, + methods=_methods, + ) + + +def _get_class_structure( + klass: Type[Any], +) -> ClassDef: + if dataclasses.is_dataclass(klass): + class_struct = _get_dataclass_structure(klass) + elif inspect.isclass(klass): + class_struct = _get_classic_class_structure(klass) + else: + raise Exception('The given class is not actually a class.') + + class_struct.module = klass.__module__ + class_struct.name = _get_fullname(klass) + + class_struct.bases = [] + for ref_class in _get_base_classes(klass): + class_struct.bases.append(_get_fullname(ref_class)) + + return class_struct + + +def _search_modules( + target: str, + exclude_pattern: list[str] = ['__pycache__'], +) -> list[str]: + """ + Search for Python modules in a given path, excluding specified patterns. + + Parameters + ---------- + target : str + Target directory to search for modules. + exclude_pattern : list, optional + Patterns to exclude from the search, by default ['__pycache__']. + + Returns + ------- + list + A list of module file paths. + """ + results = [] + for f in glob.glob('{}/**/*'.format(target), recursive=True): + skip = False + for x in exclude_pattern: + if x in f: + skip = True + break + if not skip and f.endswith('.py'): + results.append(f) + + return results + + +def _extract_filename(filename: str) -> str: + return filename.split(os.sep)[-1].split('.')[0] + + +def _extract_module_name(module_path: str) -> tuple[str, str]: + """ + Extract the module name from its file path. + + Parameters + ---------- + module_path : str + The file path of the module. + + Returns + ------- + tuple[str, str] + Returns the module path and the module name. + """ + # Extract the module name from the path. + # This needs to be adapted depending on your project's structure. + # Example: 'path/to/module.py' -> 'path.to.module' + module_split = module_path.split(os.sep) + module_path = os.sep.join(module_split[:-1]) + module_filename = module_split[-1] + + if module_filename.endswith('.py'): + module_name = module_filename[:-3] + else: + module_name = module_filename + return module_path, module_name + + +def _get_classes_from_module(module_path: str) -> list[Type[Any]]: + """ + Extract classes from a given module path using importlib.import_module. + + Parameters + ---------- + module_path : str + The path to the module from which classes are to be extracted. + + Returns + ------- + list + A list of class objects. + """ + module_path, module_name = _extract_module_name(module_path) + original_path = copy.deepcopy(sys.path) + + sys.path.insert(0, module_path) + try: + module = importlib.import_module(module_name) + except KeyboardInterrupt: + raise_error('KeyboardInterrupt', 1) + except Exception as e: + short_module_path = '.'.join(module_path.split('/')[-3:]) + print(f' Error loading module {short_module_path} '.center(80, '=')) + print(e) + print('.' * 80) + sys.path = original_path + return [] + + # If __all__ is defined, get only the classes listed in __all__ + all_classes_exported = [] + if hasattr(module, '__all__'): + for name in module.__all__: + if not inspect.isclass(getattr(module, name)): + continue + all_classes_exported.append(getattr(module, name)) + + # Get all classes defined directly in the module + all_classes = [] + for name in dir(module): + if not ( + inspect.isclass(getattr(module, name)) + and getattr(getattr(module, name), '__module__', None) + == module.__name__ + ): + continue + all_classes.append(getattr(module, name)) + sys.path = original_path + return all_classes + all_classes_exported + + +def load_classes_definition( + source: Path, + exclude: str, + verbose: bool = False, +) -> list[ClassDef]: + """ + Load classes definition from the source code located at the specified path. + + Parameters + ---------- + source : Path + The path to the source code. + exclude: pattern that excludes directories, modules or classes + verbose : bool, optional + Flag to enable verbose logging, by default False. + + Returns + ------- + ClassDef + + Raises + ------ + FileNotFoundError + If the provided path does not exist. + ValueError + If the provided path is not a directory. + """ + classes_list = [] + module_files = [] + + path_str = str(source) + + if not os.path.exists(path_str): + raise_error(f'Path "{path_str}" doesn\'t exist.', 1) + if os.path.isdir(path_str): + sys.path.insert(0, path_str) + if exclude: + exclude_pattern = [ + exclude.strip() for exclude in exclude.split(',') + ] + else: + exclude_pattern = [] + exclude_pattern.append('__pycache__') + module_files.extend( + _search_modules(path_str, exclude_pattern=exclude_pattern) + ) + else: + module_files.append(path_str) + + for file_path in module_files: + classes_list.extend(_get_classes_from_module(file_path)) + + return [_get_class_structure(cls) for cls in classes_list] diff --git a/src/umlizer/utils.py b/src/umlizer/utils.py index 559b187..7e95940 100644 --- a/src/umlizer/utils.py +++ b/src/umlizer/utils.py @@ -4,6 +4,8 @@ from typing import Any +import typer + def blob_to_regex(blob: str) -> str: """ @@ -45,3 +47,10 @@ def is_function(obj: Any) -> bool: False otherwise. """ return inspect.isroutine(obj) + + +def raise_error(message: str, exit_code: int = 1) -> None: + """Raise an error using typer.""" + red_text = typer.style(message, fg=typer.colors.RED, bold=True) + typer.echo(red_text, err=True, color=True) + raise typer.Exit(exit_code) From 28d4894ebc7eaaeb67db4716d39e9f4189e4174d Mon Sep 17 00:00:00 2001 From: Ivan Ogasawara Date: Wed, 26 Jun 2024 12:46:34 -0400 Subject: [PATCH 14/18] split into class graph and inspector --- src/umlizer/cli.py | 52 +------------------------------------------- src/umlizer/utils.py | 51 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 51 deletions(-) diff --git a/src/umlizer/cli.py b/src/umlizer/cli.py index 790abf6..9ba24e9 100644 --- a/src/umlizer/cli.py +++ b/src/umlizer/cli.py @@ -1,9 +1,6 @@ """Main module template with example functions.""" from __future__ import annotations -import os -import subprocess - from pathlib import Path import typer @@ -13,58 +10,11 @@ from typing_extensions import Annotated from umlizer import __version__, class_graph, inspector +from umlizer.utils import dot2svg, make_absolute app = typer.Typer() -def dot2svg(target: Path) -> None: - """ - Run the `dot` command to convert a Graphviz file to SVG format. - - Parameters - ---------- - target : str - The target Graphviz file to be converted. - """ - command = f'dot -Tsvg {target} -o {target}.svg' - try: - result = subprocess.run( - command, - shell=True, - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - print(result.stdout.decode()) - except subprocess.CalledProcessError as e: - print(f'Error occurred: {e.stderr.decode()}') - - -def make_absolute(relative_path: Path) -> Path: - """ - Convert a relative Path to absolute, relative to the current cwd. - - Parameters - ---------- - relative_path : Path - The path to be converted to absolute. - - Returns - ------- - Path - The absolute path. - """ - # Get current working directory - current_directory = Path(os.getcwd()) - - # Return absolute path - return ( - current_directory / relative_path - if not relative_path.is_absolute() - else relative_path - ) - - @app.callback(invoke_without_command=True) def main( ctx: Context, diff --git a/src/umlizer/utils.py b/src/umlizer/utils.py index 7e95940..071b8c9 100644 --- a/src/umlizer/utils.py +++ b/src/umlizer/utils.py @@ -1,7 +1,10 @@ """A set of utilitary tools.""" import inspect +import os import re +import subprocess +from pathlib import Path from typing import Any import typer @@ -54,3 +57,51 @@ def raise_error(message: str, exit_code: int = 1) -> None: red_text = typer.style(message, fg=typer.colors.RED, bold=True) typer.echo(red_text, err=True, color=True) raise typer.Exit(exit_code) + + +def make_absolute(relative_path: Path) -> Path: + """ + Convert a relative Path to absolute, relative to the current cwd. + + Parameters + ---------- + relative_path : Path + The path to be converted to absolute. + + Returns + ------- + Path + The absolute path. + """ + # Get current working directory + current_directory = Path(os.getcwd()) + + # Return absolute path + return ( + current_directory / relative_path + if not relative_path.is_absolute() + else relative_path + ) + + +def dot2svg(target: Path) -> None: + """ + Run the `dot` command to convert a Graphviz file to SVG format. + + Parameters + ---------- + target : str + The target Graphviz file to be converted. + """ + command = f'dot -Tsvg {target} -o {target}.svg' + try: + result = subprocess.run( + command, + shell=True, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + print(result.stdout.decode()) + except subprocess.CalledProcessError as e: + print(f'Error occurred: {e.stderr.decode()}') From 7aba486dff4bd61c196d215d62052f50e031bc69 Mon Sep 17 00:00:00 2001 From: Ivan Ogasawara Date: Wed, 26 Jun 2024 14:02:55 -0400 Subject: [PATCH 15/18] add flag to create a diagram from a yml file --- src/umlizer/cli.py | 22 +++++++++++++++------- src/umlizer/inspector.py | 8 ++++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/umlizer/cli.py b/src/umlizer/cli.py index 9ba24e9..b1e5464 100644 --- a/src/umlizer/cli.py +++ b/src/umlizer/cli.py @@ -10,6 +10,7 @@ from typing_extensions import Annotated from umlizer import __version__, class_graph, inspector +from umlizer.inspector import dict_to_classdef from umlizer.utils import dot2svg, make_absolute app = typer.Typer() @@ -68,6 +69,9 @@ def class_( verbose: Annotated[ bool, typer.Option(help='Active the verbose mode.') ] = False, + from_yaml: Annotated[ + bool, typer.Option(help='Create the class diagram from a yaml file.') + ] = False, ) -> None: """Run the command for class graph.""" source = make_absolute(source) @@ -78,14 +82,18 @@ def class_( django.setup(django_settings) - classes_nodes = inspector.load_classes_definition( - source, exclude=exclude, verbose=verbose - ) - - with open(f'{target}.yaml', 'w') as f: - yaml.dump( - [c.__dict__ for c in classes_nodes], f, indent=2, sort_keys=False + if not from_yaml: + classes_nodes = inspector.load_classes_definition( + source, exclude=exclude, verbose=verbose ) + classes_metadata = [c.__dict__ for c in classes_nodes] + with open(f'{target}.yaml', 'w') as f: + yaml.dump(classes_metadata, f, indent=2, sort_keys=False) + else: + with open(source, 'r') as f: + classes_metadata = yaml.safe_load(f) + + classes_nodes = dict_to_classdef(classes_metadata) g = class_graph.create_diagram(classes_nodes, verbose=verbose) g.format = 'png' diff --git a/src/umlizer/inspector.py b/src/umlizer/inspector.py index 8bda589..e343835 100644 --- a/src/umlizer/inspector.py +++ b/src/umlizer/inspector.py @@ -359,3 +359,11 @@ def load_classes_definition( classes_list.extend(_get_classes_from_module(file_path)) return [_get_class_structure(cls) for cls in classes_list] + + +def dict_to_classdef(classes_list: list[dict[str, Any]]) -> list[ClassDef]: + """Convert class metadata from dict to ClassDef.""" + classes_list_def: list[ClassDef] = [] + for klass_metadata in classes_list: + classes_list_def.append(ClassDef(**klass_metadata)) + return classes_list_def From daa0b5d7206f86126dfe2d9115d3574f022216d8 Mon Sep 17 00:00:00 2001 From: Ivan Ogasawara Date: Wed, 26 Jun 2024 21:28:05 -0400 Subject: [PATCH 16/18] Use full package/module path for the class name --- src/umlizer/inspector.py | 71 ++++++++++++++++++++++++++++--------- tests/ecommerce/offering.py | 51 ++++++++++++++++++++++++++ tests/ecommerce/order.py | 2 +- tests/ecommerce/product.py | 16 --------- 4 files changed, 106 insertions(+), 34 deletions(-) create mode 100644 tests/ecommerce/offering.py delete mode 100644 tests/ecommerce/product.py diff --git a/src/umlizer/inspector.py b/src/umlizer/inspector.py index e343835..444a9f5 100644 --- a/src/umlizer/inspector.py +++ b/src/umlizer/inspector.py @@ -31,13 +31,49 @@ class ClassDef: ) +def get_full_class_path(cls: Type[Any], root_path: Path) -> str: + """ + Get the full package path for a given class, including parent packages. + + Parameters + ---------- + cls : Type[Any] + The class to inspect. + root_path : Path + The root path of the project to determine the full package path. + + Returns + ------- + str + The full package path of the class. + """ + module = cls.__module__ + module_file = importlib.import_module(module).__file__ + + if module_file is None: + raise ValueError(f'The module file {module} is invalid.') + + root_path_str = str(root_path) + + if not module_file.startswith(root_path_str): + raise ValueError( + f'The module file {module_file} is not within the ' + f'root path {root_path}' + ) + + relative_path = os.path.relpath(module_file, root_path_str) + package_path = os.path.splitext(relative_path)[0].replace(os.sep, '.') + + return f'{package_path}.{cls.__qualname__}' + + def _get_fullname(entity: Type[Any]) -> str: """ Get the fully qualified name of a given entity. Parameters ---------- - entity : types.ModuleType + entity : Type[Any] The entity for which the full name is required. Returns @@ -45,12 +81,13 @@ def _get_fullname(entity: Type[Any]) -> str: str Fully qualified name of the entity. """ - if hasattr(entity, '__module__'): - return f'{entity.__module__}.{entity.__name__}' - elif hasattr(entity, '__name__'): - return entity.__name__ + module = getattr(entity, '__module__', '') + qualname = getattr(entity, '__qualname__', str(entity)) + + if module: + return module + '.' + qualname - return str(entity) + return qualname def _get_method_annotation(method: types.FunctionType) -> dict[str, str]: @@ -99,9 +136,9 @@ def _get_dataclass_structure( def _get_base_classes(klass: Type[Any]) -> list[Type[Any]]: return [ - c - for c in klass.__mro__ - if c.__name__ not in ('object', klass.__name__) + base_class + for base_class in getattr(klass, '__bases__', []) + if base_class.__name__ != 'object' ] @@ -160,9 +197,8 @@ def _get_classic_class_structure(klass: Type[Any]) -> ClassDef: value = klass_anno.get(k, 'UNKNOWN') fields[k] = getattr(value, '__value__', str(value)) - if not fields: - # Extract attributes from the `__init__` method if defined there. - fields = _get_init_attributes(klass) + # Extract attributes from the `__init__` method if defined there. + fields.update(_get_init_attributes(klass)) return ClassDef( fields=fields, @@ -170,9 +206,7 @@ def _get_classic_class_structure(klass: Type[Any]) -> ClassDef: ) -def _get_class_structure( - klass: Type[Any], -) -> ClassDef: +def _get_class_structure(klass: Type[Any], root_path: Path) -> ClassDef: if dataclasses.is_dataclass(klass): class_struct = _get_dataclass_structure(klass) elif inspect.isclass(klass): @@ -181,7 +215,7 @@ def _get_class_structure( raise Exception('The given class is not actually a class.') class_struct.module = klass.__module__ - class_struct.name = _get_fullname(klass) + class_struct.name = get_full_class_path(klass, root_path) class_struct.bases = [] for ref_class in _get_base_classes(klass): @@ -356,9 +390,12 @@ def load_classes_definition( module_files.append(path_str) for file_path in module_files: + if verbose: + print(file_path) + classes_list.extend(_get_classes_from_module(file_path)) - return [_get_class_structure(cls) for cls in classes_list] + return [_get_class_structure(cls, source) for cls in classes_list] def dict_to_classdef(classes_list: list[dict[str, Any]]) -> list[ClassDef]: diff --git a/tests/ecommerce/offering.py b/tests/ecommerce/offering.py new file mode 100644 index 0000000..0826008 --- /dev/null +++ b/tests/ecommerce/offering.py @@ -0,0 +1,51 @@ +from abc import ABC + + +class Offering(ABC): + def __init__(self, offering_id: int, name: str) -> None: + self.offering_id: int = offering_id + self.name: str = name + + +class Product(Offering): + """Represents a product in the e-commerce system.""" + + def __init__( + self, product_id: int, name: str, price: float, stock: int + ) -> None: + super().__init__(product_id, name) + self.price: float = price + self.stock: int = stock + + def update_stock(self, amount: int) -> None: + """Updates the stock quantity for the product.""" + self.stock += amount + + def get_product_info(self) -> str: + """Returns the product's information.""" + return ( + f'Product ID: {self.product_id}, Name: {self.name}, ' + f'Price: ${self.price}, Stock: {self.stock}' + ) + + +class Service(Offering): + """Represents a service in the e-commerce system.""" + + def __init__( + self, service_id: int, name: str, rate: float, duration: int + ) -> None: + super().__init__(service_id, name) + self.rate: float = rate + self.duration: int = duration # duration in minutes + + def update_duration(self, additional_minutes: int) -> None: + """Updates the duration for the service.""" + self.duration += additional_minutes + + def get_service_info(self) -> str: + """Returns the service's information.""" + return ( + f'Service ID: {self.service_id}, Name: {self.name}, ' + f'Rate: ${self.rate}/hr, Duration: {self.duration} minutes' + ) diff --git a/tests/ecommerce/order.py b/tests/ecommerce/order.py index 00df81b..f50e9ee 100644 --- a/tests/ecommerce/order.py +++ b/tests/ecommerce/order.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import List from user import User, Address -from product import Product +from offering import Product class Order: diff --git a/tests/ecommerce/product.py b/tests/ecommerce/product.py deleted file mode 100644 index 771db37..0000000 --- a/tests/ecommerce/product.py +++ /dev/null @@ -1,16 +0,0 @@ -class Product: - """Represents a product in the e-commerce system.""" - - def __init__(self, product_id: int, name: str, price: float, stock: int): - self.product_id: int = product_id - self.name: str = name - self.price: float = price - self.stock: int = stock - - def update_stock(self, amount: int) -> None: - """Updates the stock quantity for the product.""" - self.stock += amount - - def get_product_info(self) -> str: - """Returns the product's information.""" - return f'Product ID: {self.product_id}, Name: {self.name}, Price: ${self.price}, Stock: {self.stock}' From 0b5ca49c4ca67ef69242d909593ce3ce5215c419 Mon Sep 17 00:00:00 2001 From: Ivan Ogasawara Date: Wed, 26 Jun 2024 22:09:55 -0400 Subject: [PATCH 17/18] fix issues with identically module names --- src/umlizer/inspector.py | 65 ++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/src/umlizer/inspector.py b/src/umlizer/inspector.py index 444a9f5..ef637c3 100644 --- a/src/umlizer/inspector.py +++ b/src/umlizer/inspector.py @@ -288,55 +288,60 @@ def _extract_module_name(module_path: str) -> tuple[str, str]: return module_path, module_name -def _get_classes_from_module(module_path: str) -> list[Type[Any]]: +def _get_classes_from_module(module_file_path: str) -> list[Type[Any]]: """ - Extract classes from a given module path using importlib.import_module. + Extract classes from a given module path using importlib. Parameters ---------- - module_path : str - The path to the module from which classes are to be extracted. + module_file_path : str + The path to the module file from which classes are to be extracted. Returns ------- list A list of class objects. """ - module_path, module_name = _extract_module_name(module_path) + module_path, module_name = _extract_module_name(module_file_path) original_path = copy.deepcopy(sys.path) sys.path.insert(0, module_path) try: - module = importlib.import_module(module_name) + spec = importlib.util.spec_from_file_location( + module_name, module_file_path + ) + if spec is None: + raise ImportError(f'Cannot find spec for {module_file_path}') + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) # type: ignore + sys.path = original_path + + all_classes_exported = [] + + if hasattr(module, '__all__'): + all_classes_exported = [ + getattr(module, name) + for name in module.__all__ + if inspect.isclass(getattr(module, name)) + ] + + all_classes = [ + getattr(module, name) + for name in dir(module) + if inspect.isclass(getattr(module, name)) + and getattr(getattr(module, name), '__module__', None) + == module.__name__ + ] except KeyboardInterrupt: raise_error('KeyboardInterrupt', 1) except Exception as e: - short_module_path = '.'.join(module_path.split('/')[-3:]) + short_module_path = '.'.join(module_path.split(os.sep)[-3:]) print(f' Error loading module {short_module_path} '.center(80, '=')) print(e) print('.' * 80) sys.path = original_path return [] - - # If __all__ is defined, get only the classes listed in __all__ - all_classes_exported = [] - if hasattr(module, '__all__'): - for name in module.__all__: - if not inspect.isclass(getattr(module, name)): - continue - all_classes_exported.append(getattr(module, name)) - - # Get all classes defined directly in the module - all_classes = [] - for name in dir(module): - if not ( - inspect.isclass(getattr(module, name)) - and getattr(getattr(module, name), '__module__', None) - == module.__name__ - ): - continue - all_classes.append(getattr(module, name)) - sys.path = original_path return all_classes + all_classes_exported @@ -390,10 +395,12 @@ def load_classes_definition( module_files.append(path_str) for file_path in module_files: + classes_from_module = _get_classes_from_module(file_path) + classes_list.extend(classes_from_module) if verbose: + print('=' * 80) print(file_path) - - classes_list.extend(_get_classes_from_module(file_path)) + print(classes_from_module) return [_get_class_structure(cls, source) for cls in classes_list] From 8a93399247b793064289d57327a568b628a7e1df Mon Sep 17 00:00:00 2001 From: Ivan Ogasawara Date: Thu, 27 Jun 2024 19:45:50 -0400 Subject: [PATCH 18/18] fix text alignment --- src/umlizer/class_graph.py | 5 +++++ src/umlizer/inspector.py | 12 +++++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/umlizer/class_graph.py b/src/umlizer/class_graph.py index c4736ea..59915ad 100644 --- a/src/umlizer/class_graph.py +++ b/src/umlizer/class_graph.py @@ -52,6 +52,11 @@ def _get_entity_class_uml(klass: ClassDef) -> str: if k != 'return' ] m_params = ', '.join(m_params_raw) + + if m_params and len(m_params) > 20: # noqa: PLR2004 + indent = '\\l    ' + m_params = indent + m_params.replace(', ', f',{indent}') + '\\l' + methods_raw.append(f'{m_visibility} {m_name}({m_params}): {m_type}') methods = '\\l'.join(methods_raw) + '\\l' diff --git a/src/umlizer/inspector.py b/src/umlizer/inspector.py index ef637c3..185187b 100644 --- a/src/umlizer/inspector.py +++ b/src/umlizer/inspector.py @@ -48,18 +48,16 @@ def get_full_class_path(cls: Type[Any], root_path: Path) -> str: The full package path of the class. """ module = cls.__module__ - module_file = importlib.import_module(module).__file__ + imported_module = importlib.import_module(module) + module_file = getattr(imported_module, '__file__', None) if module_file is None: - raise ValueError(f'The module file {module} is invalid.') + return _get_fullname(cls) root_path_str = str(root_path) if not module_file.startswith(root_path_str): - raise ValueError( - f'The module file {module_file} is not within the ' - f'root path {root_path}' - ) + return _get_fullname(cls) relative_path = os.path.relpath(module_file, root_path_str) package_path = os.path.splitext(relative_path)[0].replace(os.sep, '.') @@ -219,7 +217,7 @@ def _get_class_structure(klass: Type[Any], root_path: Path) -> ClassDef: class_struct.bases = [] for ref_class in _get_base_classes(klass): - class_struct.bases.append(_get_fullname(ref_class)) + class_struct.bases.append(get_full_class_path(ref_class, root_path)) return class_struct