diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eb0dabcad7a7510de90899d1a651ed70f791767..0d5a5ac2ef93edca05c8e977b4ebb99f0dd3008e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed ### +- Added additional customization options to the plantuml module. +- The to_graphics function in the plantuml module uses a temporary directory now for creating the output files. + ### Deprecated ### ### Removed ### diff --git a/src/caosdb/common/models.py b/src/caosdb/common/models.py index 6475bc99ec825e102d5eac1b38d506247c11ebcb..3421f9ce39fc848f774b5d5d38280434354da8de 100644 --- a/src/caosdb/common/models.py +++ b/src/caosdb/common/models.py @@ -125,7 +125,6 @@ class Entity(object): self.id = id self.state = None - def copy(self): """ Return a copy of entity. diff --git a/src/caosdb/utils/plantuml.py b/src/caosdb/utils/plantuml.py index be34b2604f3682bb71b48bbd73e00fe854b3af51..acf218c8297028acba1cbcceabd9ba4398b0e7aa 100644 --- a/src/caosdb/utils/plantuml.py +++ b/src/caosdb/utils/plantuml.py @@ -34,10 +34,15 @@ plantuml FILENAME.pu -> FILENAME.png """ import os +import shutil import caosdb as db from caosdb.common.datatype import is_reference, get_referenced_recordtype +from typing import Optional + +import tempfile + REFERENCE = "REFERENCE" @@ -79,13 +84,23 @@ class Grouped(object): return self.parents -def recordtypes_to_plantuml_string(iterable): +def recordtypes_to_plantuml_string(iterable, + add_properties: bool = True, + add_recordtypes: bool = True, + add_legend: bool = True, + style: str = "default"): """Converts RecordTypes into a string for PlantUML. This function obtains an iterable and returns a string which can be input into PlantUML for a representation of all RecordTypes in the iterable. + Current options for style + ------------------------- + + "default" - Standard rectangles with uml class circle and methods section + "salexan" - Round rectangles, hide circle and methods section + Current limitations ------------------- @@ -96,6 +111,8 @@ def recordtypes_to_plantuml_string(iterable): - Inheritance of Properties is not rendered nicely at the moment. """ + # TODO: This function needs a review of python type hints. + classes = [el for el in iterable if isinstance(el, db.RecordType)] dependencies = {} @@ -140,74 +157,87 @@ def recordtypes_to_plantuml_string(iterable): return result result = "@startuml\n\n" - result += "skinparam classAttributeIconSize 0\n" - result += "package Properties #DDDDDD {\n" + if style == "default": + result += "skinparam classAttributeIconSize 0\n" + elif style == "salexan": + result += """skinparam roundcorner 20\n +skinparam boxpadding 20\n +\n +hide methods\n +hide circle\n +""" + else: + raise ValueError("Unknown style.") - for p in properties: - inheritances[p] = p.get_parents() - dependencies[p] = [] + if add_properties: + result += "package Properties #DDDDDD {\n" + for p in properties: + inheritances[p] = p.get_parents() + dependencies[p] = [] - result += "class \"{klass}\" << (P,#008800) >> {{\n".format(klass=p.name) + result += "class \"{klass}\" << (P,#008800) >> {{\n".format(klass=p.name) - if p.description is not None: - result += get_description(p.description) - result += "\n..\n" + if p.description is not None: + result += get_description(p.description) + result += "\n..\n" - if isinstance(p.datatype, str): - result += "datatype: " + p.datatype + "\n" - elif isinstance(p.datatype, db.Entity): - result += "datatype: " + p.datatype.name + "\n" - else: - result += "datatype: " + str(p.datatype) + "\n" + if isinstance(p.datatype, str): + result += "datatype: " + p.datatype + "\n" + elif isinstance(p.datatype, db.Entity): + result += "datatype: " + p.datatype.name + "\n" + else: + result += "datatype: " + str(p.datatype) + "\n" + result += "}\n\n" result += "}\n\n" - result += "}\n\n" - result += "package RecordTypes #DDDDDD {\n" + if add_recordtypes: + result += "package RecordTypes #DDDDDD {\n" - for c in classes: - inheritances[c] = c.get_parents() - dependencies[c] = [] - result += "class \"{klass}\" << (C,#FF1111) >> {{\n".format(klass=c.name) + for c in classes: + inheritances[c] = c.get_parents() + dependencies[c] = [] + result += "class \"{klass}\" << (C,#FF1111) >> {{\n".format(klass=c.name) - if c.description is not None: - result += get_description(c.description) + if c.description is not None: + result += get_description(c.description) - props = "" - props += _add_properties(c, importance=db.FIX) - props += _add_properties(c, importance=db.OBLIGATORY) - props += _add_properties(c, importance=db.RECOMMENDED) - props += _add_properties(c, importance=db.SUGGESTED) + props = "" + props += _add_properties(c, importance=db.FIX) + props += _add_properties(c, importance=db.OBLIGATORY) + props += _add_properties(c, importance=db.RECOMMENDED) + props += _add_properties(c, importance=db.SUGGESTED) - if len(props) > 0: - result += "__Properties__\n" + props - else: - result += "\n..\n" - result += "}\n\n" + if len(props) > 0: + result += "__Properties__\n" + props + else: + result += "\n..\n" + result += "}\n\n" - for g in grouped: - inheritances[g] = g.get_parents() - result += "class \"{klass}\" << (G,#0000FF) >> {{\n".format(klass=g.name) - result += "}\n\n" + for g in grouped: + inheritances[g] = g.get_parents() + result += "class \"{klass}\" << (G,#0000FF) >> {{\n".format(klass=g.name) + result += "}\n\n" - for c, parents in inheritances.items(): - for par in parents: - result += "\"{par}\" <|-- \"{klass}\"\n".format( - klass=c.name, par=par.name) + for c, parents in inheritances.items(): + for par in parents: + result += "\"{par}\" <|-- \"{klass}\"\n".format( + klass=c.name, par=par.name) - for c, deps in dependencies.items(): - for dep in deps: - result += "\"{klass}\" *-- \"{dep}\"\n".format( - klass=c.name, dep=dep) + for c, deps in dependencies.items(): + for dep in deps: + result += "\"{klass}\" *-- \"{dep}\"\n".format( + klass=c.name, dep=dep) - result += """ + if add_legend: + result += """ package \"B is a subtype of A\" <<Rectangle>> { A <|-right- B note "This determines what you find when you query for the RecordType.\\n'FIND RECORD A' will provide Records which have a parent\\nA or B, while 'FIND RECORD B' will provide only Records which have a parent B." as N1 } """ - result += """ + result += """ package \"The property P references an instance of D\" <<Rectangle>> { class C { @@ -246,7 +276,8 @@ def retrieve_substructure(start_record_types, depth, result_id_set=None, result_ Returns ------- db.Container - A container containing all the retrieved entites or None if cleanup is False. + A container containing all the retrieved entites + or None if cleanup is False. """ # Initialize the id set and result container for level zero recursion depth: if result_id_set is None: @@ -260,9 +291,11 @@ def retrieve_substructure(start_record_types, depth, result_id_set=None, result_ result_container.append(entity) result_id_set.add(entity.id) for prop in entity.properties: - if is_reference(prop.datatype) and prop.datatype != db.FILE and depth > 0: - rt = db.RecordType(name=get_referenced_recordtype(prop.datatype)).retrieve() - retrieve_substructure([rt], depth-1, result_id_set, result_container, False) + if (is_reference(prop.datatype) and prop.datatype != db.FILE and depth > 0): + rt = db.RecordType( + name=get_referenced_recordtype(prop.datatype)).retrieve() + retrieve_substructure([rt], depth-1, result_id_set, + result_container, False) if prop.id not in result_id_set: result_container.append(prop) @@ -274,14 +307,22 @@ def retrieve_substructure(start_record_types, depth, result_id_set=None, result_ result_container.append(rt) result_id_set.add(parent.id) if depth > 0: - retrieve_substructure([rt], depth-1, result_id_set, result_container, False) + retrieve_substructure([rt], depth-1, result_id_set, + result_container, False) if cleanup: return result_container return None -def to_graphics(recordtypes, filename): +def to_graphics(recordtypes: list[db.Entity], filename: str, + output_dirname: Optional[str] = None, + formats: list[str] = ["tsvg"], + silent: bool = True, + add_properties: bool = True, + add_recordtypes: bool = True, + add_legend: bool = True, + style: str = "default"): """Calls recordtypes_to_plantuml_string(), saves result to file and creates an svg image @@ -293,17 +334,52 @@ def to_graphics(recordtypes, filename): Iterable with the entities to be displayed. filename : str filename of the image without the extension(e.g. data_structure; + also without the preceeding path. data_structure.pu and data_structure.svg will be created.) + output_dirname : str + the destination directory for the resulting images as defined by the "-o" + option by plantuml + default is to use current working dir + formats : list[str] + list of target formats as defined by the -t"..." options by plantuml, e.g. "tsvg" + silent : bool + Don't output messages. """ - pu = recordtypes_to_plantuml_string(recordtypes) - - pu_filename = filename+".pu" - with open(pu_filename, "w") as pu_file: - pu_file.write(pu) - - cmd = "plantuml -tsvg %s" % pu_filename - print("Executing:", cmd) - - if os.system(cmd) != 0: - raise Exception("An error occured during the execution of plantuml. " - "Is plantuml installed?") + pu = recordtypes_to_plantuml_string(recordtypes, + add_properties, + add_recordtypes, + add_legend, + style) + + if output_dirname is None: + output_dirname = os.getcwd() + + allowed_formats = [ + "tpng", "tsvg", "teps", "tpdf", "tvdx", "txmi", + "tscxml", "thtml", "ttxt", "tutxt", "tlatex", "tlatex:nopreamble"] + + with tempfile.TemporaryDirectory() as td: + + pu_filename = os.path.join(td, filename + ".pu") + with open(pu_filename, "w") as pu_file: + pu_file.write(pu) + + for format in formats: + extension = format[1:] + if ":" in extension: + extension = extension[:extension.index(":")] + + if format not in allowed_formats: + raise RuntimeError("Format not allowed.") + cmd = "plantuml -{} {}".format(format, pu_filename) + if not silent: + print("Executing:", cmd) + + if os.system(cmd) != 0: # TODO: replace with subprocess.run + raise Exception("An error occured during the execution of " + "plantuml when using the format {}. " + "Is plantuml installed? " + "You might want to dry a different format.".format(format)) + # copy only the final product into the target directory + shutil.copy(os.path.join(td, filename + "." + extension), + output_dirname) diff --git a/unittests/test_plantuml.py b/unittests/test_plantuml.py new file mode 100644 index 0000000000000000000000000000000000000000..a507c36b2d3a4246205fc7507cb05119c575084c --- /dev/null +++ b/unittests/test_plantuml.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# encoding: utf-8 +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2022 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2022 Henrik tom Wörden <h.tomwoerden@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header +# + +""" +test plantuml utility +""" + +import tempfile +import pytest +import caosdb as db +import shutil +from caosdb.utils.plantuml import to_graphics + + +@pytest.fixture +def setup_n_teardown(autouse=True): + + with tempfile.TemporaryDirectory() as td: + global output + output = td + yield + + +@pytest.fixture +def entities(): + return [db.RecordType(name="TestRT1").add_property("testprop"), + db.RecordType(name="TestRT2").add_property("testprop2"), + db.Property("testprop")] + + +@pytest.mark.skipif(shutil.which('plantuml') is None, reason="No plantuml found") +def test_to_graphics1(entities, setup_n_teardown): + to_graphics(entities, "data_model", output_dirname=output) + + +@pytest.mark.skipif(shutil.which('plantuml') is None, reason="No plantuml found") +def test_to_graphics2(entities, setup_n_teardown): + to_graphics(entities, "data_model", output_dirname=output, formats=["tpng", "tsvg"], + add_properties=False, add_legend=False, style="salexan")