Source code for linkml.generators.yumlgen

"""Generate yuml

https://yuml.me/diagram/scruffy/class/samples

"""

import os
from dataclasses import dataclass
from typing import Callable, List, Optional, Set, cast

import click
import requests
from linkml_runtime.linkml_model.meta import ClassDefinition, ClassDefinitionName, SlotDefinition
from linkml_runtime.utils.formatutils import camelcase, underscore
from rdflib import Namespace

from linkml import REQUESTS_TIMEOUT
from linkml.utils.generator import Generator, shared_arguments

yuml_is_a = "^-"
yuml_uses = "uses -.->"
yuml_injected = "< -.- inject"
yuml_slot_type = ":"
yuml_inline = "++- "
yuml_inline_rev = "-++"
yuml_ref = "- "

yuml_base = "https://yuml.me/diagram/nofunky"
yuml_scale = ""  # ';scale:180' ';scale:80' for small
yuml_dir = ";dir:TB"  # '
yuml_class = "/class/"
YUML = Namespace(yuml_base + yuml_scale + yuml_dir + yuml_class)


[docs]@dataclass class YumlGenerator(Generator): generatorname = os.path.basename(__file__) generatorversion = "0.1.1" valid_formats = ["yuml", "png", "pdf", "jpg", "json", "svg"] visit_all_class_slots = False referenced: Optional[Set[ClassDefinitionName]] = None # List of classes that have to be emitted generated: Optional[Set[ClassDefinitionName]] = None # List of classes that have been emitted box_generated: Optional[Set[ClassDefinitionName]] = None # Class boxes that have been emitted associations_generated: Optional[Set[ClassDefinitionName]] = None # Classes with associations generated focus_classes: Optional[Set[ClassDefinitionName]] = None # Classes to be completely filled gen_classes: Optional[Set[ClassDefinitionName]] = None # Classes to be generated output_file_name: Optional[str] = None # Location of output file if directory used classes: Set[ClassDefinitionName] = None directory: Optional[str] = None diagram_name: Optional[str] = None load_image: bool = True
[docs] def visit_schema( self, classes: Set[ClassDefinitionName] = None, directory: Optional[str] = None, diagram_name: Optional[str] = None, load_image: bool = True, **_, ) -> Optional[str]: if directory: os.makedirs(directory, exist_ok=True) if classes is not None: for cls in classes: if cls not in self.schema.classes: raise ValueError(f"Unknown class name: {cls}") self.box_generated = set() self.associations_generated = set() self.focus_classes = classes if classes: self.gen_classes = self.neighborhood(list(classes)).classrefs.union(classes) else: self.gen_classes = self.synopsis.roots.classrefs self.referenced = self.gen_classes self.generated = set() yumlclassdef: List[str] = [] while self.referenced.difference(self.generated): cn = sorted(list(self.referenced.difference(self.generated)), reverse=True)[0] self.generated.add(cn) assocs = self.class_associations(ClassDefinitionName(cn), cn in self.referenced) if assocs: yumlclassdef.append(assocs) else: yumlclassdef.append(self.class_box(ClassDefinitionName(cn))) file_suffix = ".svg" if self.format == "yuml" else "." + self.format file_name = diagram_name or camelcase(sorted(classes)[0] if classes else self.schema.name) if directory: self.output_file_name = os.path.join( directory, file_name + file_suffix, ) if load_image: payload = "dsl_text=" + (",".join(yumlclassdef)) payload = payload.replace("%3F", "?").replace("%2B", "+") url = "https://yuml.me/diagram/plain/class/" resp = requests.post(url, data=payload, timeout=REQUESTS_TIMEOUT) if resp.ok: filename = resp.text.strip().replace(".svg", file_suffix) resp = requests.get(f"https://yuml.me/{filename}", stream=True, timeout=REQUESTS_TIMEOUT) with open(self.output_file_name, "wb") as f: for chunk in resp.iter_content(chunk_size=2048): f.write(chunk) else: self.logger.error(f"{resp.reason} accessing {url}: {resp!r}") else: return str(YUML) + ",".join(yumlclassdef)
[docs] def class_box(self, cn: ClassDefinitionName) -> str: """Generate a box for the class. Populate its interior only if (a) it hasn't previously been generated and (b) it appears in the gen_classes list @param cn: @return: """ slot_defs: List[str] = [] if cn not in self.box_generated and (not self.focus_classes or cn in self.focus_classes): cls = self.schema.classes[cn] for slot in self.filtered_cls_slots(cn, all_slots=True, filtr=lambda s: s.range not in self.schema.classes): if True or cn in slot.domain_of: mod = self.prop_modifier(cls, slot) slot_defs.append( underscore(self.aliased_slot_name(slot)) + mod + ":" + underscore(slot.range) + self.cardinality(slot) ) self.box_generated.add(cn) self.referenced.add(cn) return "[" + camelcase(cn) + ("|" + ";".join(slot_defs) if slot_defs else "") + "]"
[docs] def class_associations(self, cn: ClassDefinitionName, must_render: bool = False) -> str: """Emit all associations for a focus class. If none are specified, all classes are generated @param cn: Name of class to be emitted @param must_render: True means render even if this is a target (class is specifically requested) @return: YUML representation of the association """ # NOTE: YUML diagrams draw in the opposite order in which they are created, so we work from bottom to top and # from right to left assocs: List[str] = [] if cn not in self.associations_generated and (not self.focus_classes or cn in self.focus_classes): cls = self.schema.classes[cn] # Slots that reference other classes for slot in self.filtered_cls_slots(cn, False, lambda s: s.range in self.schema.classes)[::-1]: # Swap the two boxes because, in the case of self reference, the last definition wins if slot.range not in self.associations_generated and cn in slot.domain_of: rhs = self.class_box(cn) lhs = self.class_box(cast(ClassDefinitionName, slot.range)) assocs.append( lhs + "<" + self.aliased_slot_name(slot) + self.prop_modifier(cls, slot) + self.cardinality(slot, False) + (yuml_inline_rev if slot.inlined else yuml_ref) + rhs ) # Slots in other classes that reference this for slotname in sorted(self.synopsis.rangerefs.get(cn, [])): slot = self.schema.slots[slotname] # Don't do self references twice # Also, slot must be owned by the class if cls.name not in slot.domain_of and cls.name not in self.associations_generated: for dom in [self.schema.classes[dof] for dof in slot.domain_of]: assocs.append( self.class_box(dom.name) + (yuml_inline if slot.inlined else yuml_ref) + self.aliased_slot_name(slot) + self.prop_modifier(dom, slot) + self.cardinality(slot, False) + ">" + self.class_box(cn) ) # Mixins used in the class for mixin in cls.mixins: assocs.append(self.class_box(cn) + yuml_uses + self.class_box(mixin)) # Classes that use the class as a mixin if cls.name in self.synopsis.mixinrefs: for mixin in sorted(self.synopsis.mixinrefs[cls.name].classrefs, reverse=True): assocs.append(self.class_box(ClassDefinitionName(mixin)) + yuml_uses + self.class_box(cn)) # Classes that inject information if cn in self.synopsis.applytos.classrefs: for injector in sorted(self.synopsis.applytorefs[cn].classrefs, reverse=True): assocs.append(self.class_box(cn) + yuml_injected + self.class_box(ClassDefinitionName(injector))) self.associations_generated.add(cn) # Children if cn in self.synopsis.isarefs: for is_a_cls in sorted(self.synopsis.isarefs[cn].classrefs, reverse=True): assocs.append(self.class_box(cn) + yuml_is_a + self.class_box(ClassDefinitionName(is_a_cls))) # Parent if cls.is_a and cls.is_a not in self.associations_generated: assocs.append(self.class_box(cls.is_a) + yuml_is_a + self.class_box(cn)) return ",".join(assocs)
[docs] @staticmethod def cardinality(slot: SlotDefinition, is_attribute: bool = True) -> str: if is_attribute: if slot.multivalued: return " %2B" if slot.required else " *" else: return "" if slot.required else " %3F" else: if slot.multivalued: return " 1..*" if slot.required else " 0..*" else: return " 1..1" if slot.required else " 0..1"
[docs] def filtered_cls_slots( self, cn: ClassDefinitionName, all_slots: bool = True, filtr: Callable[[SlotDefinition], bool] = lambda: True, ) -> List[SlotDefinition]: """Return the set of slots associated with the class that meet the filter criteria. Slots will be returned in defining order, with class slots returned last @param cn: name of class to filter @param all_slots: True means include attributes @param filtr: Slot filter predicate @return: List of slot definitions """ rval = [] cls = self.schema.classes[cn] cls_slots = self.all_slots(cls, cls_slots_first=True) for slot in cls_slots: if (all_slots or slot.range in self.schema.classes) and filtr(slot): rval.append(slot) return rval
[docs] def prop_modifier(self, cls: ClassDefinition, slot: SlotDefinition) -> str: """Return the modifiers for the slot: (i) - inherited (m) - inherited through mixin (a) - injected (pk) - primary ckey @param cls: @param slot: @return: """ pk = "(pk)" if slot.key else "" inherited = slot.name not in self.own_slot_names(cls) mixin = inherited and slot.name in [mslot.name for mslot in [self.schema.classes[m] for m in cls.mixins]] injected = cls.name in self.synopsis.applytos.classrefs and slot.name in [ aslot.name for aslot in [self.schema.classes[a] for a in sorted(self.synopsis.applytorefs[cls.name].classrefs)] ] return pk + ("(a)" if injected else "(m)" if mixin else "(i)" if inherited else "")
@shared_arguments(YumlGenerator) @click.command() @click.option("--classes", "-c", multiple=True, help="Class(es) to emit") @click.option( "--directory", "-d", help="Output directory - if supplied, YUML rendering will be saved in file", ) @click.option( "--diagram-name", help="Name of the diagram in the output directory (without suffix!)", ) def cli(yamlfile, **args): """Generate a UML representation of a LinkML model""" print(YumlGenerator(yamlfile, **args).serialize(**args), end="") if __name__ == "__main__": cli()