├── .gitignore ├── LICENSE ├── README.md ├── docs └── library_management.png ├── frappeviz ├── __init__.py ├── __main__.py └── frappeviz.py ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | #Mac Specific 2 | .DS_Store 3 | 4 | #VS Code 5 | .vscode 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # PlantUml Jar file 16 | *.jar 17 | 18 | # Distribution / packaging 19 | .Python 20 | build/ 21 | develop-eggs/ 22 | dist/ 23 | downloads/ 24 | eggs/ 25 | .eggs/ 26 | lib/ 27 | lib64/ 28 | parts/ 29 | sdist/ 30 | var/ 31 | wheels/ 32 | share/python-wheels/ 33 | *.egg-info/ 34 | .installed.cfg 35 | *.egg 36 | MANIFEST 37 | 38 | # PyInstaller 39 | # Usually these files are written by a python script from a template 40 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 41 | *.manifest 42 | *.spec 43 | 44 | # Installer logs 45 | pip-log.txt 46 | pip-delete-this-directory.txt 47 | 48 | # Unit test / coverage reports 49 | htmlcov/ 50 | .tox/ 51 | .nox/ 52 | .coverage 53 | .coverage.* 54 | .cache 55 | nosetests.xml 56 | coverage.xml 57 | *.cover 58 | *.py,cover 59 | .hypothesis/ 60 | .pytest_cache/ 61 | cover/ 62 | 63 | # Translations 64 | *.mo 65 | *.pot 66 | 67 | # Django stuff: 68 | *.log 69 | local_settings.py 70 | db.sqlite3 71 | db.sqlite3-journal 72 | 73 | # Flask stuff: 74 | instance/ 75 | .webassets-cache 76 | 77 | # Scrapy stuff: 78 | .scrapy 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # PyBuilder 84 | .pybuilder/ 85 | target/ 86 | 87 | # Jupyter Notebook 88 | .ipynb_checkpoints 89 | 90 | # IPython 91 | profile_default/ 92 | ipython_config.py 93 | 94 | # pyenv 95 | # For a library or package, you might want to ignore these files since the code is 96 | # intended to run in multiple environments; otherwise, check them in: 97 | # .python-version 98 | 99 | # pipenv 100 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 101 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 102 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 103 | # install all needed dependencies. 104 | #Pipfile.lock 105 | 106 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 107 | __pypackages__/ 108 | 109 | # Celery stuff 110 | celerybeat-schedule 111 | celerybeat.pid 112 | 113 | # SageMath parsed files 114 | *.sage.py 115 | 116 | # Environments 117 | .env 118 | .venv 119 | env/ 120 | venv/ 121 | ENV/ 122 | env.bak/ 123 | venv.bak/ 124 | 125 | # Spyder project settings 126 | .spyderproject 127 | .spyproject 128 | 129 | # Rope project settings 130 | .ropeproject 131 | 132 | # mkdocs documentation 133 | /site 134 | 135 | # mypy 136 | .mypy_cache/ 137 | .dmypy.json 138 | dmypy.json 139 | 140 | # Pyre type checker 141 | .pyre/ 142 | 143 | # pytype static type analyzer 144 | .pytype/ 145 | 146 | # Cython debug symbols 147 | cython_debug/ 148 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Yemi Kudaisi 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Frappe App Visualizer 2 | 3 | A python app for visualizing class diagrams of a [Frappe App's](https://frappeframework.com/) doctypes using [PlantUML](https://plantuml.com/) 4 | 5 | ![Screenshot](https://github.com/yemikudaisi/frappe_viz/raw/master/docs/library_management.png) 6 | 7 | ## Dependencies 8 | 9 | - [Python 3](https://www.python.org/download/releases/3.0/) 10 | - [PlantUML](https://pypi.org/project/plantuml/) 11 | 12 | ## Installation 13 | 14 | ``` 15 | $ pip install frappeviz 16 | ``` 17 | 18 | ## Usage 19 | ### Command Line 20 | 21 | ``` 22 | $ frappeviz [-h] [--output output-dir] [--format {txt,img,all}] 23 | frappe-app-directory 24 | ``` 25 | 26 | #### Arguments 27 | - -h: help 28 | - --output / -o: output directory 29 | - --format / -f: Output format (txt | img | all) 30 | - frappe directory 31 | 32 | #### Example 33 | 34 | ```sh 35 | $ frappeviz path/to/frappe/app/dir -o /path/to/output/dir -f img 36 | ``` 37 | 38 | ### Module 39 | 40 | ```python 41 | >>> from frappeviz import generate_uml 42 | >>> generate_uml('path/to/frappe/app/dir', '/path/to/output/dir' 'img') 43 | ``` 44 | 45 | The UML for each module in the app is generated in separate files (PlantUML text and .png images) that shares the same name as the app's respective modules. 46 | 47 | ## Supported Environment 48 | Tested on the following OS: 49 | - Ubuntu OS 50 | - macOS 51 | -------------------------------------------------------------------------------- /docs/library_management.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yemikudaisi/frappeviz/2c935ee4c5f88966e31b2ae0eb053c75f89e2cc4/docs/library_management.png -------------------------------------------------------------------------------- /frappeviz/__init__.py: -------------------------------------------------------------------------------- 1 | from frappeviz.frappeviz import cmdline 2 | from frappeviz.frappeviz import generate_uml -------------------------------------------------------------------------------- /frappeviz/__main__.py: -------------------------------------------------------------------------------- 1 | from frappeviz import cmdline 2 | 3 | if __name__ == '__main__': 4 | cmdline() -------------------------------------------------------------------------------- /frappeviz/frappeviz.py: -------------------------------------------------------------------------------- 1 | # frappeviz.py 2 | import argparse 3 | import os 4 | import sys 5 | from sys import modules 6 | import json 7 | 8 | frappe_app_modules = [] 9 | 10 | def is_frappe_app_folder(path_to_app): 11 | """Check if a modules.txt file exists within a python module (folder) that shares exactly 12 | the same name as the frappe app dir supplied 13 | """ 14 | 15 | global frappe_app_name,frappe_app_modules 16 | frappe_app_name = os.path.basename(os.path.normpath(path_to_app)) 17 | module_file_path = os.path.join(path_to_app, frappe_app_name, 'modules.txt') 18 | 19 | # If a 'modules.txt' file exists 20 | if os.path.isfile(module_file_path): 21 | 22 | # fetch app modules from file content in to a global variable 23 | with open(module_file_path, "r") as modules: 24 | for l in modules.readlines(): 25 | frappe_app_modules.append(l.replace('\n','')) 26 | return True 27 | 28 | return False 29 | 30 | class Composition: 31 | """ Python class that represents a class composition relationship 32 | """ 33 | def __init__(self, class_name): 34 | self.owner_class_name = class_name 35 | 36 | def has(self, other_class_name, comment = ''): 37 | """Add a composition relationship between owner class to another class 38 | """ 39 | self.child_class_name = other_class_name 40 | self.comment = comment 41 | return self 42 | 43 | def __str__(self): 44 | """PlantUML string representation of class diagram""" 45 | output = '"%s" *-- "%s"' % (self.child_class_name,self.owner_class_name) 46 | 47 | #check if a comment was supplied 48 | if not self.comment == '': 49 | output += ': %s' % (self.comment) 50 | 51 | return output 52 | 53 | class Extension: 54 | """ Python class for extension class relartionship 55 | """ 56 | def __init__(self, class_name): 57 | self.child_class_name = class_name 58 | 59 | def extends(self, other_class_name, comment = ''): 60 | self.parent_class_name = other_class_name 61 | self.comment = comment 62 | return self 63 | 64 | def __str__(self): 65 | """PlantUML string representation of class diagram""" 66 | output = '"%s" <|- "%s" : %s' % (self.parent_class_name,self.child_class_name) 67 | 68 | #check if a comment was supplied 69 | if not self.comment == '': 70 | output += ': %s' % (self.comment) 71 | 72 | class Docfield: 73 | """ Python representing docfield in a frappe doctype 74 | """ 75 | def __init__(self, name, type_name): 76 | self.name = name 77 | self.type_name = type_name 78 | 79 | def __str__(self): 80 | return '%s : %s' % (self.name, self.type_name) 81 | 82 | class ClassGenerator: 83 | """Doctype class diagram UML generator 84 | """ 85 | def __init__(self, class_name): 86 | self.class_name = class_name 87 | self.fields = [] 88 | self.relationships = [] 89 | 90 | def addField(self, fieldObj): 91 | """ Adds a docfield to a class""" 92 | if fieldObj['fieldtype'] == 'Link': 93 | self.relationships.append(Composition(self.class_name).has(fieldObj['options'],fieldObj['fieldname'])) 94 | self.fields.append(Docfield(fieldObj['fieldname'], fieldObj['fieldtype'])) 95 | 96 | def to_plantuml(self): 97 | """ Returns PlantUML class diagram for doctype in text format 98 | """ 99 | output = '' 100 | 101 | for r in self.relationships: 102 | output += '\n'+str(r) 103 | 104 | output += '\n class "%s" {' % (self.class_name) 105 | for f in self.fields: 106 | output += '\n '+str(f) 107 | output += '\n }\n' 108 | return output 109 | 110 | def generate_doctype_uml(doctype_name, fields): 111 | """Generates class diagram for doctype given a list of fields 112 | """ 113 | gen = ClassGenerator(doctype_name) 114 | for f in fields: 115 | gen.addField(f) 116 | return gen.to_plantuml() 117 | 118 | def get_folder_name(module_name): 119 | """ Basically converts 120 | 'Hello World' to 'hello_word' 121 | """ 122 | return module_name.lower().replace(' ','_') 123 | 124 | def generate_plantuml_graphics(): 125 | """Generate plantuml image for corresponding plantuml files in output folder. 126 | """ 127 | for filename in os.listdir(output_dir): 128 | if filename.endswith('.plantuml'): 129 | command = 'python3 -m plantuml %s' % os.path.join(output_dir,filename) 130 | os.system(command) 131 | continue 132 | 133 | def write_app_module_output(module_file_name, module_uml): 134 | """Writes the plantuml uml text for a module to file given a module file name 135 | """ 136 | if output_dir: 137 | if not os.path.isdir(output_dir): 138 | print('output directory does not exist') 139 | else: 140 | file = open(os.path.join(output_dir,module_file_name+'.plantuml'),"w") 141 | file.write(module_uml) 142 | file.close() 143 | 144 | def build_plantuml_text(): 145 | # Build UML packages and classes for respective modules and doctypes 146 | module_uml_list = [] 147 | for m in frappe_app_modules: 148 | module_path = os.path.join(frappe_app_dir,frappe_app_name,get_folder_name(m)) 149 | if os.path.isdir(module_path): 150 | module_doctype_dir = os.path.join(module_path, 'doctype') 151 | if os.path.isdir(module_doctype_dir): 152 | module_uml = '@startuml\npackage %s.%s <> {' % (frappe_app_name,get_folder_name(m)) 153 | for filename in os.listdir(module_doctype_dir): 154 | doctype_file = os.path.join(module_doctype_dir,filename, filename+'.json') 155 | if os.path.isfile(doctype_file): 156 | with open(doctype_file) as f: 157 | data = json.load(f) 158 | module_uml += generate_doctype_uml(data['name'], data['fields']) 159 | 160 | module_uml += '}\n@enduml' 161 | module_uml_list.append({ "name": m, "uml": module_uml}) 162 | return module_uml_list 163 | 164 | def print_plantuml_text(): 165 | # Generates UML text and writes to file 166 | umls = build_plantuml_text() 167 | for u in umls: 168 | print(u['uml']) 169 | print('='*20) 170 | 171 | def generate_plantuml_text(): 172 | # Generates UML text and writes to file 173 | umls = build_plantuml_text() 174 | for u in umls: 175 | write_app_module_output(get_folder_name(u['name']), u['uml']) 176 | 177 | def generate_output(): 178 | 179 | if not os.path.isdir(frappe_app_dir): 180 | print('Frappe app directory does not exist') 181 | sys.exit() 182 | 183 | # Validate frappe app directory passed as argument 184 | if is_frappe_app_folder(frappe_app_dir): 185 | print('Generating UML for ' + frappe_app_name) 186 | else: 187 | print('Directory is not a frappe app.') 188 | sys.exit() 189 | 190 | if output_dir: 191 | 192 | if not output_format: 193 | print('Output format not specified') 194 | sys.exit() 195 | 196 | if output_format == 'txt' or output_format == 'all': 197 | generate_plantuml_text() 198 | print('PlatUML text files generated') 199 | 200 | if output_format == 'img' or output_format == 'all': 201 | # if only images are required, generate the text and delete after images have been created 202 | 203 | if output_format == 'img': 204 | generate_plantuml_text() 205 | generate_plantuml_graphics() 206 | filelist = [ f for f in os.listdir(output_dir) if f.endswith(".plantuml") ] 207 | for f in filelist: 208 | os.remove(os.path.join(output_dir, f)) 209 | else: 210 | generate_plantuml_graphics() 211 | 212 | print('UML image files generated') 213 | else: 214 | print_plantuml_text() 215 | 216 | def generate_uml(app, out=False, out_format = 'all'): 217 | global frappe_app_dir, output_dir, output_format 218 | frappe_app_dir = app 219 | output_dir = out 220 | output_format = out_format 221 | generate_output() 222 | 223 | def cmdline(): 224 | # Create the parser 225 | arg_parser = argparse.ArgumentParser(description='Generates class diagram for Frappe Framewrok app.') 226 | 227 | # Add the arguments 228 | arg_parser.add_argument('app_dir', 229 | metavar='frappe-app-directory', 230 | type=str, 231 | help='the path to frappe app') 232 | 233 | arg_parser.add_argument( 234 | '--output', '-o', 235 | metavar='output-dir', 236 | help="Output directory") 237 | 238 | arg_parser.add_argument('--format', '-f', 239 | choices=['txt', 'img', 'all'], 240 | help="Specifies output format") 241 | 242 | # Parse arguments 243 | args = arg_parser.parse_args() 244 | 245 | global frappe_app_dir, output_dir, output_format 246 | frappe_app_dir = args.app_dir 247 | output_dir = args.output 248 | output_format = args.format 249 | 250 | generate_output() 251 | 252 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | plantuml==0.3.0 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="frappeviz", # Replace with your own username 8 | version="0.0.1", 9 | author="Yemi Kudaisi", 10 | author_email="contact@yemikudaisi.online", 11 | description="Python app for visualizing class diagrams for a Frappe Framework App using PlantUML", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/yemikudaisi/frappeviz", 15 | packages=setuptools.find_packages(), 16 | classifiers=[ 17 | "Programming Language :: Python :: 3", 18 | "License :: OSI Approved :: MIT License", 19 | "Operating System :: OS Independent", 20 | ], 21 | python_requires='>=3.6', 22 | install_requires=[ 23 | "plantuml==0.3.0", 24 | ], 25 | entry_points = { 26 | "console_scripts": [ 27 | "frappeviz = frappeviz.__main__:cmdline", 28 | ] 29 | } 30 | ) --------------------------------------------------------------------------------