Skip to content

Commit

Permalink
Implement Markdown docs output.
Browse files Browse the repository at this point in the history
  • Loading branch information
balhoff committed Apr 29, 2021
1 parent d6af668 commit 4278581
Show file tree
Hide file tree
Showing 12 changed files with 273 additions and 24 deletions.
9 changes: 7 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,23 @@ libraryDependencies ++= {
Seq(
"dev.zio" %% "zio" % zioVersion,
"dev.zio" %% "zio-streams" % zioVersion,
"com.github.alexarchambault" %% "case-app" % "2.0.4",
"com.github.alexarchambault" %% "case-app" % "2.0.6",
"net.sourceforge.owlapi" % "owlapi-distribution" % "4.5.19",
"org.phenoscape" %% "scowl" % "1.3.4",
"org.phenoscape" %% "owlet" % "1.8.1" exclude("org.slf4j", "slf4j-log4j12"),
"org.semanticweb.elk" % "elk-owlapi" % "0.4.3" exclude("org.slf4j", "slf4j-log4j12"),
"net.sourceforge.owlapi" % "org.semanticweb.hermit" % "1.4.3.456",
"net.sourceforge.owlapi" % "jfact" % "4.0.4",
"org.geneontology" %% "owl-diff" % "1.2.2",
"io.circe" %% "circe-yaml" % "0.13.1",
"io.circe" %% "circe-core" % "0.13.0",
"io.circe" %% "circe-generic" % "0.13.0",
"io.circe" %% "circe-parser" % "0.13.0",
"org.obolibrary.robot" % "robot-core" % "1.7.0" exclude("org.slf4j", "slf4j-log4j12"),
"org.obolibrary.robot" % "robot-core" % "1.7.0"
exclude("org.slf4j", "slf4j-log4j12")
exclude("org.geneontology", "whelk_2.12")
exclude("org.geneontology", "whelk-owlapi_2.12")
exclude("org.geneontology", "owl-diff_2.12"),
"com.github.pathikrit" %% "better-files" % "3.9.1",
"org.apache.jena" % "apache-jena-libs" % "3.16.0" exclude("org.slf4j", "slf4j-log4j12"),
"com.github.tototoshi" %% "scala-csv" % "1.3.7",
Expand Down
83 changes: 83 additions & 0 deletions src/main/scala/org/monarchinitiative/dosdp/DocsMarkdown.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package org.monarchinitiative.dosdp

import org.monarchinitiative.dosdp.cli.Docs.DocData
import org.phenoscape.scowl._
import org.semanticweb.owlapi.io.OWLObjectRenderer
import org.semanticweb.owlapi.model.{IRI, OWLObject}

import scala.jdk.CollectionConverters._

object DocsMarkdown {

def markdown(edosdp: ExpandedDOSDP, docData: DocData, renderer: OWLObjectRenderer, data: List[List[String]]): String = {
def r(obj: OWLObject): String = renderer.render(obj).replace("\n", " ")
val dosdp = edosdp.dosdp
val PatternIRI = dosdp.pattern_iri.map(IRI.create).getOrElse(IRI.create("http://example.org/"))
val PatternCls = Class(PatternIRI)
val equivPrefix = if (docData.equivalentTo.flatMap(_.getClassExpressionsMinus(PatternCls).asScala).size > 1) "- " else ""
val subClassOfPrefix = if (docData.subClassOf.size > 1) "- " else ""
val variables = ((edosdp.varExpressions.to(List) ::: edosdp.listVarExpressions.to(List)).map { case (k,v) => k -> r(v) }) :::
dosdp.data_vars.getOrElse(Map.empty).to(List) ::: dosdp.data_list_vars.getOrElse(Map.empty).to(List)

s"""# ${dosdp.pattern_name.getOrElse("")}

[${dosdp.pattern_iri.getOrElse("Missing pattern IRI")}](${dosdp.pattern_iri.getOrElse("")})

## Description

${dosdp.description.getOrElse("_No description_")}

## Variables

| Variable name | Allowed type |
|:--------------|:-------------|
${variables.map {case (name, range) => s"| `{$name}` | $range |" }.mkString("\n")}

## Name

${docData.name.map { ax => r(ax.getValue)}.mkString("\n\n")}

## Annotations

${docData.annotations.map { ax =>
s"- ${r(ax.getProperty)}: ${r(ax.getValue)}"
}.mkString("\n")
}

## Definition

${docData.definition.map { ax => r(ax.getValue)}.mkString("\n\n")}

## Equivalent to

${docData.equivalentTo.flatMap { ax =>
ax.getClassExpressionsMinus(PatternCls).asScala.map(cls => r(cls)).map(text => s"$equivPrefix$text")
}.mkString("\n")}

${if (docData.subClassOf.nonEmpty) "## Subclass of\n" else ""}
${docData.subClassOf.map(ax => r(ax.getSuperClass)).map(text => s"$subClassOfPrefix$text").mkString("\n")}

${if (docData.otherAxioms.nonEmpty) "## Other axioms\n" else ""}
${docData.otherAxioms.map(r).map(text => s"- $text").mkString("\n")}

## Data preview

*See full table [here](${docData.dataLocation})*

${data.head.mkString("| ", " | ", " |")}
${data.head.map(_ => "|:--").mkString}|
${data.drop(1).map(_.mkString("| ", " | ", " |")).mkString("\n")}

"""
}

def indexMarkdown(patterns: List[(DOSDP, String)]): String = {
s"""# Design Patterns

| Pattern | Description |
|:--------|:------------|
${patterns.map { case (p, filename) => s"| [${p.pattern_name.getOrElse("*unnamed*")}]($filename) | ${p.description.getOrElse("*no description*")} |" }.mkString("\n")}
"""
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ final case class ExpandedDOSDP(dosdp: DOSDP, prefixes: PartialFunction[String, S
vars.view.mapValues(expressionParser.parse).toMap
}

def listVarExpressions: Map[String, OWLClassExpression] = {
val vars = dosdp.list_vars.getOrElse(Map.empty)
vars.view.mapValues(expressionParser.parse).toMap
}

private def expressionFor(template: PrintfText, bindings: Option[Map[String, SingleValue]]): Option[OWLClassExpression] =
template.replaced(bindings).map(expressionParser.parse)

Expand Down
15 changes: 15 additions & 0 deletions src/main/scala/org/monarchinitiative/dosdp/cli/Config.scala
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,21 @@ final case class PrototypeConfig(@Recurse

}

@CommandName("docs")
@HelpMessage("output Markdown documentation for patterns")
final case class DocsConfig(@Recurse
common: CommonOptions,
@HelpMessage("Input file (TSV or CSV)")
@ValueDescription("file")
infile: String = "fillers.tsv",
@HelpMessage("URL prefix for linking to data files")
@ValueDescription("URL")
dataLocationPrefix: String = "http://example.org/") extends Config {

override def run: ZIO[zio.ZEnv, DOSDPError, Unit] = Docs.run(this)

}

@CommandName("query")
@HelpMessage("query an ontology for terms matching a Dead Simple OWL Design Pattern")
final case class QueryConfig(@Recurse
Expand Down
141 changes: 141 additions & 0 deletions src/main/scala/org/monarchinitiative/dosdp/cli/Docs.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package org.monarchinitiative.dosdp.cli

import com.github.tototoshi.csv.CSVFormat
import org.geneontology.owl.differ.ManchesterSyntaxOWLObjectRenderer
import org.geneontology.owl.differ.shortform.MarkdownLinkShortFormProvider
import org.monarchinitiative.dosdp.Utilities.isDirectory
import org.monarchinitiative.dosdp.cli.Generate.readFillers
import org.monarchinitiative.dosdp.cli.Prototype.OboInOwlSource
import org.monarchinitiative.dosdp.{DOSDP, DocsMarkdown, ExpandedDOSDP, Prefixes}
import org.phenoscape.scowl._
import org.semanticweb.owlapi.apibinding.OWLManager
import org.semanticweb.owlapi.io.OWLObjectRenderer
import org.semanticweb.owlapi.model._
import org.semanticweb.owlapi.util.AnnotationValueShortFormProvider
import zio._
import zio.blocking._

import java.io.{File, PrintWriter}
import scala.jdk.CollectionConverters._

object Docs {

private val Definition = AnnotationProperty("http://purl.obolibrary.org/obo/IAO_0000115")
private val Special = Set(RDFSLabel, Definition)

def run(config: DocsConfig): ZIO[ZEnv, DOSDPError, Unit] = {
for {
sepFormat <- ZIO.fromEither(Config.tabularFormat(config.common.tableFormat))
targets <- determineTargets(config).mapError(e => DOSDPError("Failure to configure input or output", e))
ontologyOpt <- config.common.ontologyOpt
ontology = ontologyOpt.getOrElse(OWLManager.createOWLOntologyManager().createOntology())
_ <- ZIO.foreach_(targets)(processTarget(_, sepFormat, config, ontology))
_ <- writeIndex(targets, config.common.outfile).when(config.common.batchPatterns.items.nonEmpty)
} yield ()
}

private def processTarget(target: DocsTarget, sepFormat: CSVFormat, config: DocsConfig, ontology: OWLOntology): ZIO[Blocking, DOSDPError, Unit] = {
for {
_ <- ZIO.effectTotal(scribe.info(s"Processing pattern ${target.templateFile}"))
prefixes <- config.common.prefixesMap
dosdp <- Config.inputDOSDPFrom(target.templateFile)
eDOSDP = ExpandedDOSDP(dosdp, prefixes)
columnsAndFillers <- readFillers(new File(target.inputFile), sepFormat)
(columns, rows) = columnsAndFillers
prefixes <- config.common.prefixesMap
ontologyOpt <- config.common.ontologyOpt
iri <- ZIO.fromOption(dosdp.pattern_iri).orElseFail(DOSDPError("Pattern must have pattern IRI for document command"))
fillers = dosdp.vars.getOrElse(Map.empty).map { case (k, _) => k -> s"http://dosdp.org/filler/$k" } ++
dosdp.list_vars.getOrElse(Map.empty).map { case (k, _) => k -> s"http://dosdp.org/filler/$k" } ++
dosdp.data_vars.getOrElse(Map.empty).map { case (k, _) => k -> s"`{$k}`" } ++
dosdp.data_list_vars.getOrElse(Map.empty).map { case (k, _) => k -> s"`{$k}`" } +
(DOSDP.DefinedClassVariable -> iri)
variableReadableIdentifiers = (dosdp.vars.getOrElse(Map.empty).map { case (k, _) => k -> s"http://dosdp.org/filler/$k" } ++
dosdp.list_vars.getOrElse(Map.empty).map { case (k, _) => k -> s"http://dosdp.org/filler/$k" }).map(e => IRI.create(e._2) -> s"`{${e._1}}`")
renderer = objectRenderer(ontology, variableReadableIdentifiers)
axioms <- Generate.renderPattern(dosdp, prefixes, fillers, ontologyOpt, true, true, None, false, OboInOwlSource, false, Map(RDFSLabel.getIRI -> variableReadableIdentifiers))
patternIRI = IRI.create(iri)
docAxioms = findDocAxioms(patternIRI, axioms, target, config.dataLocationPrefix)
data = columns.to(List) :: rows.take(5).map(formatDataRow(_, columns.to(List), prefixes)) ::: Nil
markdown = DocsMarkdown.markdown(eDOSDP, docAxioms, renderer, data)
_ <- effectBlockingIO(new PrintWriter(target.outputFile, "utf-8")).bracketAuto { writer =>
effectBlockingIO(writer.print(markdown))
}.mapError(e => DOSDPError(s"Couldn't write Markdown to file ${target.outputFile}", e))
} yield ()
}

private def writeIndex(targets: List[DocsTarget], outpath: String) = {
for {
dosdpsAndOutfiles <- ZIO.foreach(targets)(target => Config.inputDOSDPFrom(target.templateFile).map(_ -> new File(target.outputFile).getName))
markdown = DocsMarkdown.indexMarkdown(dosdpsAndOutfiles)
_ <- effectBlockingIO(new PrintWriter(s"$outpath/index.md", "utf-8")).bracketAuto { writer =>
effectBlockingIO(writer.print(markdown))
}.mapError(e => DOSDPError(s"Couldn't write Markdown to file $outpath/index.md", e))
} yield DocsMarkdown.indexMarkdown(dosdpsAndOutfiles)
}

private def determineTargets(config: DocsConfig): ZIO[Blocking, Throwable, List[DocsTarget]] = {
val patternNames = config.common.batchPatterns.items
if (patternNames.nonEmpty) for {
_ <- ZIO.effectTotal(scribe.info("Running in batch mode"))
_ <- ZIO.ifM(isDirectory(config.common.template))(ZIO.unit,
ZIO.fail(DOSDPError("\"--template must be a directory in batch mode\"")))
_ <- ZIO.ifM(isDirectory(config.infile))(ZIO.unit,
ZIO.fail(DOSDPError("\"--infile must be a directory in batch mode\"")))
_ <- ZIO.ifM(isDirectory(config.common.outfile))(ZIO.unit,
ZIO.fail(DOSDPError("\"--outfile must be a directory in batch mode\"")))
} yield patternNames.map { pattern =>
val templateFileName = s"${config.common.template}/$pattern.yaml"
val dataExtension = config.common.tableFormat.toLowerCase
val dataFileName = s"${config.infile}/$pattern.$dataExtension"
val outFileName = s"${config.common.outfile}/$pattern.md"
DocsTarget(templateFileName, dataFileName, outFileName)
}
else ZIO.succeed(List(DocsTarget(config.common.template, config.infile, config.common.outfile)))
}

//TODO better handling for list values?
private def formatDataRow(row: Map[String, String], columns: List[String], prefixes: PartialFunction[String, String]): List[String] =
columns.map(c => row.getOrElse(c, "")).map(v => Prefixes.idToIRI(v, prefixes).map(iri => s"[$v](${iri})").getOrElse(v))

private final case class DocsTarget(templateFile: String, inputFile: String, outputFile: String)

final case class DocData(
name: Set[OWLAnnotationAssertionAxiom],
definition: Set[OWLAnnotationAssertionAxiom],
annotations: Set[OWLAnnotationAssertionAxiom],
equivalentTo: Set[OWLEquivalentClassesAxiom],
subClassOf: Set[OWLSubClassOfAxiom],
otherAxioms: Set[OWLAxiom],
dataLocation: String
)

private def findDocAxioms(namedClassIRI: IRI, axioms: Set[OWLAxiom], target: DocsTarget, dataLocationPrefix: String): DocData = {
val PatternIRI = namedClassIRI
val PatternCls = Class(namedClassIRI)
val names = axioms.collect { case ax @ AnnotationAssertion(_, RDFSLabel, PatternIRI, _) => ax }
val definitions = axioms.collect { case ax @ AnnotationAssertion(_, Definition, PatternIRI, _) => ax }
val annotations = axioms.collect { case ax @ AnnotationAssertion(_, prop, PatternIRI, _) if !Special(prop) => ax }
val equivalentTo = axioms.collect { case ax @ EquivalentClasses(_, clss) if clss.toSet[OWLClassExpression](PatternCls) => ax }
val subClassOf = axioms.collect { case ax @ SubClassOf(_, PatternCls, _) => ax }
val other = axioms -- names -- definitions -- annotations -- equivalentTo -- subClassOf
val dataFileName = new File(target.inputFile).getName
val dataLocation = s"$dataLocationPrefix$dataFileName"
DocData(names, definitions, annotations, equivalentTo, subClassOf, other, dataLocation)
}

def objectRenderer(ont: OWLOntology, extraReadableIdentifiers: Map[IRI, String]): OWLObjectRenderer = {
val labelProvider = new AnnotationValueShortFormProvider(List(RDFSLabel).asJava, Map.empty[OWLAnnotationProperty, java.util.List[String]].asJava, ont.getOWLOntologyManager) {
override def getShortForm(entity: OWLEntity): String = extraReadableIdentifiers.getOrElse(entity.getIRI, super.getShortForm(entity))
}
val markdownLinkProvider = new MarkdownLinkShortFormProvider(labelProvider) {
override def getShortForm(entity: OWLEntity): String =
if (entity.getIRI.toString.startsWith("http://dosdp.org/filler/")) labelProvider.getShortForm(entity)
else super.getShortForm(entity)
}
val renderer = new ManchesterSyntaxOWLObjectRenderer()
renderer.setShortFormProvider(markdownLinkProvider)
renderer
}

}
16 changes: 8 additions & 8 deletions src/main/scala/org/monarchinitiative/dosdp/cli/Generate.scala
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,20 @@ object Generate {
dosdp <- Config.inputDOSDPFrom(target.templateFile)
columnsAndFillers <- readFillers(new File(target.inputFile), sepFormat)
(columns, fillers) = columnsAndFillers
missingColumns = dosdp.allVars.diff(columns)
missingColumns = dosdp.allVars.diff(columns.to(Set))
_ <- ZIO.foreach_(missingColumns)(c => ZIO.effectTotal(scribe.warn(s"Input is missing column for pattern variable <$c>")))
axioms <- renderPattern(dosdp, prefixes, fillers, ontologyOpt, outputLogicalAxioms, outputAnnotationAxioms, config.restrictAxiomsColumn, config.addAxiomSourceAnnotation.bool, axiomSourceProperty, config.generateDefinedClass.bool)
axioms <- renderPattern(dosdp, prefixes, fillers, ontologyOpt, outputLogicalAxioms, outputAnnotationAxioms, config.restrictAxiomsColumn, config.addAxiomSourceAnnotation.bool, axiomSourceProperty, config.generateDefinedClass.bool, Map.empty)
_ <- Utilities.saveAxiomsToOntology(axioms, target.outputFile)
} yield ()
}
} yield ()

def renderPattern(dosdp: DOSDP, prefixes: PartialFunction[String, String], fillers: Map[String, String], ontOpt: Option[OWLOntology], outputLogicalAxioms: Boolean, outputAnnotationAxioms: Boolean, restrictAxiomsColumnName: Option[String], annotateAxiomSource: Boolean, axiomSourceProperty: OWLAnnotationProperty, generateDefinedClass: Boolean): IO[DOSDPError, Set[OWLAxiom]] =
renderPattern(dosdp, prefixes, List(fillers), ontOpt, outputLogicalAxioms, outputAnnotationAxioms, restrictAxiomsColumnName, annotateAxiomSource, axiomSourceProperty, generateDefinedClass)
def renderPattern(dosdp: DOSDP, prefixes: PartialFunction[String, String], fillers: Map[String, String], ontOpt: Option[OWLOntology], outputLogicalAxioms: Boolean, outputAnnotationAxioms: Boolean, restrictAxiomsColumnName: Option[String], annotateAxiomSource: Boolean, axiomSourceProperty: OWLAnnotationProperty, generateDefinedClass: Boolean, extraReadableIdentifiers: Map[IRI, Map[IRI, String]]): IO[DOSDPError, Set[OWLAxiom]] =
renderPattern(dosdp, prefixes, List(fillers), ontOpt, outputLogicalAxioms, outputAnnotationAxioms, restrictAxiomsColumnName, annotateAxiomSource, axiomSourceProperty, generateDefinedClass, extraReadableIdentifiers)

def renderPattern(dosdp: DOSDP, prefixes: PartialFunction[String, String], fillers: List[Map[String, String]], ontOpt: Option[OWLOntology], outputLogicalAxioms: Boolean, outputAnnotationAxioms: Boolean, restrictAxiomsColumnName: Option[String], annotateAxiomSource: Boolean, axiomSourceProperty: OWLAnnotationProperty, generateDefinedClass: Boolean): IO[DOSDPError, Set[OWLAxiom]] = {
def renderPattern(dosdp: DOSDP, prefixes: PartialFunction[String, String], fillers: List[Map[String, String]], ontOpt: Option[OWLOntology], outputLogicalAxioms: Boolean, outputAnnotationAxioms: Boolean, restrictAxiomsColumnName: Option[String], annotateAxiomSource: Boolean, axiomSourceProperty: OWLAnnotationProperty, generateDefinedClass: Boolean, extraReadableIdentifiers: Map[IRI, Map[IRI, String]]): IO[DOSDPError, Set[OWLAxiom]] = {
val eDOSDP = ExpandedDOSDP(dosdp, prefixes)
val readableIDIndex = ontOpt.map(ont => createReadableIdentifierIndex(eDOSDP, ont)).getOrElse(Map.empty)
val readableIDIndex = ontOpt.map(ont => createReadableIdentifierIndex(eDOSDP, ont)).getOrElse(Map.empty) |+| extraReadableIdentifiers
val knownColumns = dosdp.allVars
val generatedAxioms = ZIO.foreach(fillers) { row =>
val (varBindingsItems, localLabelItems) = (for {
Expand Down Expand Up @@ -145,15 +145,15 @@ object Generate {
else ZIO.succeed(List(GenerateTarget(config.common.template, config.infile, config.common.outfile)))
}

def readFillers(file: File, sepFormat: CSVFormat): ZIO[Blocking, DOSDPError, (Set[String], List[Map[String, String]])] =
def readFillers(file: File, sepFormat: CSVFormat): ZIO[Blocking, DOSDPError, (Seq[String], List[Map[String, String]])] =
for {
cleaned <- effectBlockingIO(Source.fromFile(file, "utf-8")).bracketAuto { source =>
effectBlockingIO(source.getLines().filterNot(_.trim.isEmpty).mkString("\n"))
}.mapError(e => DOSDPError("Unable to read input table", e))
columns <- ZIO.effectTotal(CSVReader.open(new StringReader(cleaned))(sepFormat)).bracketAuto { reader =>
ZIO.effectTotal {
val iteratorToCheckColumns = reader.iteratorWithHeaders
if (iteratorToCheckColumns.hasNext) iteratorToCheckColumns.next().keySet else Set.empty[String]
if (iteratorToCheckColumns.hasNext) iteratorToCheckColumns.next().keys.to(Seq) else Seq.empty[String]
}
}
data <- ZIO.effectTotal(CSVReader.open(new StringReader(cleaned))(sepFormat)).bracketAuto { reader =>
Expand Down
Loading

0 comments on commit 4278581

Please sign in to comment.