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/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/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..0d530b8 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" @@ -60,6 +61,7 @@ testpaths = [ [tool.bandit] exclude_dirs = ["tests"] targets = "./" +skips = ["B602"] [tool.vulture] exclude = ["tests"] @@ -75,7 +77,7 @@ verbose = false line-length = 79 force-exclude = true src = ["./"] -ignore = ["PLR0913"] +ignore = ["PLR0913", "RUF008"] exclude = [ 'docs', ] @@ -108,3 +110,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..59915ad 100644 --- a/src/umlizer/class_graph.py +++ b/src/umlizer/class_graph.py @@ -1,124 +1,20 @@ """Create graphviz for classes.""" from __future__ import annotations -import copy -import dataclasses -import glob -import importlib.util -import inspect -import os -import sys -import types - -from pathlib import Path -from typing import Any, Type, Union, cast +from typing import Any, cast import graphviz as gv -import typer - - -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 umlizer.inspector import ClassDef -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. - """ - return f'{entity.__module__}.{entity.__name__}' - - -def _get_methods(entity: Type[Any]) -> list[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. - """ - return [ - k - for k, v in entity.__dict__.items() - if not k.startswith('__') and isinstance(v, types.FunctionType) - ] - - -def _get_dataclass_structure( - klass: Type[Any], -) -> dict[str, Union[dict[str, str], list[str]]]: - fields = { - k: v.type.__name__ for k, v in klass.__dataclass_fields__.items() - } - return {'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]: - return getattr(klass, '__annotations__', {}) - - -def _get_classicclass_structure( - klass: Type[Any], -) -> dict[str, Union[dict[str, str], list[str]]]: - _methods = _get_methods(klass) - fields = {} - - for k in list(klass.__dict__.keys()): - if k.startswith('__') or k in _methods: - continue - value = _get_annotations(klass).get(k, '') - fields[k] = getattr(value, '__value__', str(value)) - - return { - 'fields': fields, - 'methods': _methods, - } - - -def _get_class_structure( - klass: Type[Any], -) -> dict[str, Union[dict[str, str], list[str]]]: - if dataclasses.is_dataclass(klass): - return _get_dataclass_structure(klass) - elif inspect.isclass(klass): - return _get_classicclass_structure(klass) - - raise Exception('The given class is not actually a class.') - - -def _get_entity_class_uml(entity: Type[Any]) -> str: +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,139 +23,51 @@ 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})' + 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 = cast(dict[str, str], class_structure['fields']) - fields = ( - '\\l'.join( - [ - f'{"-" if k.startswith("_") else "+"} {k}: {v}' - for k, v in fields_struct.items() - ] - ) - + '\\l' - ) - methods_struct = cast(list[str], class_structure['methods']) - methods = ( - '\\l'.join( - [ - f'{"-" if m.startswith("_") else "+"} {m}()' - for m in methods_struct - ] - ) - + '\\l' - ) - - # Combine class name, fields, and methods into the UML node format - uml_representation = '{' + f'{class_name}|{fields}|{methods}' + '}' - 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] - module_name = module_filename.rstrip('.py') - return module_path, module_name + fields_struct = klass.fields + 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, m_metadata in methods_struct.items(): + m_visibility = '-' if m_name.startswith('_') else '+' + 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) + if m_params and len(m_params) > 20: # noqa: PLR2004 + indent = '\\l    ' + m_params = indent + m_params.replace(', ', f',{indent}') + '\\l' -def _get_classes_from_module(module_path: str) -> list[Type[Any]]: - """ - Extract classes from a given module path using importlib.import_module. + methods_raw.append(f'{m_visibility} {m_name}({m_params}): {m_type}') - Parameters - ---------- - module_path : str - The path to the module from which classes are to be extracted. + methods = '\\l'.join(methods_raw) + '\\l' - Returns - ------- - list - A list of class objects. - """ - module_path, module_name = _extract_module_name(module_path) - original_path = copy.deepcopy(sys.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, '=')) - print(e) - print('.' * 80) - return [] - return classes_list + # Combine class name, fields, and methods into the UML node format + uml_representation = '{' + f'{class_name}|{fields}|{methods}' + '}' + return uml_representation -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,55 +75,14 @@ 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( - source: Path, verbose: bool = False -) -> gv.Digraph: - """ - Create a class diagram from the source code located at the specified path. - - Parameters - ---------- - source : Path - The path to the source code. - verbose : bool, optional - Flag to enable verbose logging, by default False. - - Returns - ------- - gv.Digraph - Graphviz Digraph object representing the class diagram. - - Raises - ------ - FileNotFoundError - If the provided path does not exist. - ValueError - If the provided path is not a directory. - """ - classes_list = [] - - 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) - - for f in _search_modules(path_str): - 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) diff --git a/src/umlizer/cli.py b/src/umlizer/cli.py index cc06493..b1e5464 100644 --- a/src/umlizer/cli.py +++ b/src/umlizer/cli.py @@ -1,45 +1,21 @@ """Main module template with example functions.""" from __future__ import annotations -import os - from pathlib import Path import typer +import yaml from typer import Context, Option from typing_extensions import Annotated -from umlizer import __version__, class_graph +from umlizer import __version__, class_graph, inspector +from umlizer.inspector import dict_to_classdef +from umlizer.utils import dot2svg, make_absolute app = typer.Typer() -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, @@ -75,18 +51,56 @@ 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/*").' + ) + ), + ] = '', + 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, + 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) target = make_absolute(target) / 'class_graph' - g = class_graph.create_class_diagram_from_source(source, verbose=verbose) + if django_settings: + from umlizer.plugins import django + + django.setup(django_settings) + + 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' g.render(target) + dot2svg(target) + if __name__ == '__main__': app() diff --git a/src/umlizer/inspector.py b/src/umlizer/inspector.py new file mode 100644 index 0000000..185187b --- /dev/null +++ b/src/umlizer/inspector.py @@ -0,0 +1,411 @@ +"""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_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__ + imported_module = importlib.import_module(module) + module_file = getattr(imported_module, '__file__', None) + + if module_file is None: + return _get_fullname(cls) + + root_path_str = str(root_path) + + if not module_file.startswith(root_path_str): + 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, '.') + + return f'{package_path}.{cls.__qualname__}' + + +def _get_fullname(entity: Type[Any]) -> str: + """ + Get the fully qualified name of a given entity. + + Parameters + ---------- + entity : Type[Any] + The entity for which the full name is required. + + Returns + ------- + str + Fully qualified name of the entity. + """ + module = getattr(entity, '__module__', '') + qualname = getattr(entity, '__qualname__', str(entity)) + + if module: + return module + '.' + qualname + + return qualname + + +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 [ + base_class + for base_class in getattr(klass, '__bases__', []) + if base_class.__name__ != 'object' + ] + + +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)) + + # Extract attributes from the `__init__` method if defined there. + fields.update(_get_init_attributes(klass)) + + return ClassDef( + fields=fields, + methods=_methods, + ) + + +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): + 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_full_class_path(klass, root_path) + + class_struct.bases = [] + for ref_class in _get_base_classes(klass): + class_struct.bases.append(get_full_class_path(ref_class, root_path)) + + 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_file_path: str) -> list[Type[Any]]: + """ + Extract classes from a given module path using importlib. + + Parameters + ---------- + 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_file_path) + original_path = copy.deepcopy(sys.path) + + sys.path.insert(0, module_path) + try: + 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(os.sep)[-3:]) + print(f' Error loading module {short_module_path} '.center(80, '=')) + print(e) + print('.' * 80) + sys.path = original_path + return [] + 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_from_module = _get_classes_from_module(file_path) + classes_list.extend(classes_from_module) + if verbose: + print('=' * 80) + print(file_path) + print(classes_from_module) + + return [_get_class_structure(cls, source) 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 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() diff --git a/src/umlizer/utils.py b/src/umlizer/utils.py new file mode 100644 index 0000000..071b8c9 --- /dev/null +++ b/src/umlizer/utils.py @@ -0,0 +1,107 @@ +"""A set of utilitary tools.""" +import inspect +import os +import re +import subprocess + +from pathlib import Path +from typing import Any + +import typer + + +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. + + 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) + + +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 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()}') diff --git a/tests/ecommerce/__init__.py b/tests/ecommerce/__init__.py new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..f50e9ee --- /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 offering 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/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}'